diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 3b81deaa264c..eb291cd2221c 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -2,8 +2,8 @@ # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 34660 CLI/cmux.swift -17954 Sources/AppDelegate.swift -16427 Sources/ContentView.swift +17958 Sources/AppDelegate.swift +16535 Sources/ContentView.swift 14270 Sources/TerminalController.swift 13172 Sources/Workspace.swift 12348 cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -17,7 +17,7 @@ 7218 cmuxTests/WorkspaceRemoteConnectionTests.swift 6359 cmuxTests/SessionPersistenceTests.swift 6255 cmuxTests/GhosttyConfigTests.swift -6166 Sources/TabManager.swift +6268 Sources/TabManager.swift 5915 cmuxTests/TerminalAndGhosttyTests.swift 5809 Sources/TextBoxInput.swift 5573 cmuxTests/BrowserConfigTests.swift @@ -45,7 +45,7 @@ 2229 Sources/TerminalWindowPortal.swift 2126 cmuxTests/CmuxConfigTests.swift 2085 cmuxTests/ShortcutAndCommandPaletteTests.swift -2078 Sources/SessionPersistence.swift +2084 Sources/SessionPersistence.swift 2011 Sources/KeyboardShortcutSettingsFileStore.swift 1944 Sources/RestorableAgentSession.swift 1900 cmuxTests/NotificationAndMenuBarTests.swift @@ -87,7 +87,7 @@ 1110 Sources/AppDelegate+CmuxSSHURL.swift 1093 cmuxUITests/BonsplitTabDragUITests.swift 1087 Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteFuzzyMatcher.swift -1044 cmuxTests/WorkspaceGroupTests.swift +1303 cmuxTests/WorkspaceGroupTests.swift 1033 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalViewportResyncTests.swift 1021 cmuxUITests/TerminalCmdClickUITests.swift 1009 cmuxTests/CmuxTopSnapshotScopeTests.swift @@ -190,7 +190,7 @@ 592 Sources/TextBoxSubmitActions.swift 591 Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/DefaultsValueModelLifecycleTests.swift 590 Sources/Panels/BrowserNavigationDelegate.swift -589 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift +647 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift 588 cmuxTests/CommandPaletteShortcutCustomizationTests.swift 586 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+PortScan.swift 586 Sources/JSONCParser.swift @@ -200,7 +200,7 @@ 580 cmuxTests/CLIHookNoResponseTests.swift 577 cmuxTests/AppearanceSettingsTests.swift 576 Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGrid.swift -574 Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceCoordinatorTests.swift +884 Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceCoordinatorTests.swift 572 Sources/Feed/FeedTextEditorDebugWindowController.swift 567 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift 567 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigDiscovery/GhosttyConfigDiscovery.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncWorkspaceListResponse.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncWorkspaceListResponse.swift index 73a2da2ca417..ba47f4e75788 100644 --- a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncWorkspaceListResponse.swift +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncWorkspaceListResponse.swift @@ -71,6 +71,8 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable { public let isCollapsed: Bool /// Whether the group is pinned on the Mac. public let isPinned: Bool + /// The parent group id when this group is nested under another group. + public let parentGroupID: String? /// The anchor workspace that owns this group. It is represented by the /// group header and never rendered as a separate row. public let anchorWorkspaceID: String @@ -84,6 +86,7 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable { case name case isCollapsed = "is_collapsed" case isPinned = "is_pinned" + case parentGroupID = "parent_group_id" case anchorWorkspaceID = "anchor_workspace_id" } } diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspacePreview+RemoteMapping.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspacePreview+RemoteMapping.swift index 9576d4c2270a..96e7cef9eddb 100644 --- a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspacePreview+RemoteMapping.swift +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspacePreview+RemoteMapping.swift @@ -31,6 +31,7 @@ extension MobileWorkspaceGroupPreview { name: remote.name, isCollapsed: remote.isCollapsed, isPinned: remote.isPinned, + parentGroupID: remote.parentGroupID.map { MobileWorkspaceGroupPreview.ID(rawValue: $0) }, anchorWorkspaceID: MobileWorkspacePreview.ID(rawValue: remote.anchorWorkspaceID) ) } diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCClientTests.swift b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCClientTests.swift index 910b1a7e5d90..2faa521566a9 100644 --- a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCClientTests.swift +++ b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCClientTests.swift @@ -175,6 +175,38 @@ import Testing #expect(withoutPreview.previewAt == nil) } + @Test func workspaceListResponseDecodesNestedGroupParent() throws { + let json = Data(""" + { + "workspaces": [], + "groups": [ + { + "id": "hotels", + "name": "Hotels", + "is_collapsed": false, + "is_pinned": false, + "anchor_workspace_id": "hotels-anchor" + }, + { + "id": "marriott", + "name": "Marriott", + "is_collapsed": true, + "is_pinned": false, + "parent_group_id": "hotels", + "anchor_workspace_id": "marriott-anchor" + } + ] + } + """.utf8) + + let response = try MobileSyncWorkspaceListResponse.decode(json) + let child = try #require(response.groups.last) + #expect(child.parentGroupID == "hotels") + + let mapped = MobileWorkspaceGroupPreview(remote: child) + #expect(mapped.parentGroupID == "hotels") + } + /// The Mac stamps `last_activity_at` on every workspace (falling back to /// creation time when there is no notification) and emits `has_unread` for /// the row's unread dot. Both must decode when present and degrade safely diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 8c099460f768..940d39562fc1 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -982,12 +982,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { terminalSubscriptionStartTask?.cancel() renderGridLivenessTimer?.cancel() renderGridLivenessProbeTask?.cancel() - terminalSubscriptionRefreshTask?.cancel() - createWorkspaceTask?.cancel() - createTerminalTask?.cancel() - workspaceListRefreshTask?.cancel() - pullToRefreshTask?.cancel() - cancelAllTerminalReplayTasks() + cancelRemoteOperationTasks() teardownSecondaryMacSubscriptions() if let remoteClient { Task { await remoteClient.disconnect() } diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceGroupPreview.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceGroupPreview.swift index de9add38c0d2..bbfe4bef11a0 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceGroupPreview.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceGroupPreview.swift @@ -35,6 +35,8 @@ public struct MobileWorkspaceGroupPreview: Identifiable, Equatable, Sendable { public var isCollapsed: Bool /// Whether the group is pinned on the Mac. public var isPinned: Bool + /// The parent group when this group is nested, or `nil` at the root. + public var parentGroupID: ID? /// The anchor workspace that owns this group. It is represented by the group /// header and never rendered as a separate row. public var anchorWorkspaceID: MobileWorkspacePreview.ID @@ -45,18 +47,21 @@ public struct MobileWorkspaceGroupPreview: Identifiable, Equatable, Sendable { /// - name: The group's user-facing name. /// - isCollapsed: Whether the group is collapsed. Defaults to `false`. /// - isPinned: Whether the group is pinned. Defaults to `false`. + /// - parentGroupID: The parent group id for nested groups. Defaults to `nil`. /// - anchorWorkspaceID: The anchor workspace that owns the group. public init( id: ID, name: String, isCollapsed: Bool = false, isPinned: Bool = false, + parentGroupID: ID? = nil, anchorWorkspaceID: MobileWorkspacePreview.ID ) { self.id = id self.name = name self.isCollapsed = isCollapsed self.isPinned = isPinned + self.parentGroupID = parentGroupID self.anchorWorkspaceID = anchorWorkspaceID } } diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListItem.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListItem.swift index 6fa3192c2ff7..929c31485766 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListItem.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListItem.swift @@ -18,19 +18,23 @@ public enum MobileWorkspaceListItem: Identifiable, Equatable, Sendable { /// anchor workspace (visible member rows carry their own dots); while /// collapsed it reflects the whole group, anchor included, so hidden /// member activity is never silently swallowed. - case groupHeader(MobileWorkspaceGroupPreview, hasUnread: Bool) + /// + /// `depth` is the normalized parent-chain depth used by the view to indent + /// nested group headers. + case groupHeader(MobileWorkspaceGroupPreview, hasUnread: Bool, depth: Int = 0) /// A workspace row. `indented` is `true` for non-anchor members nested under - /// a group header, so the view can inset them. - case workspace(MobileWorkspacePreview, indented: Bool) + /// a group header, so the view can inset them. `depth` is the containing + /// group's normalized parent-chain depth. + case workspace(MobileWorkspacePreview, indented: Bool, depth: Int = 0) /// A stable, list-unique identity for SwiftUI diffing. Namespaced by item /// kind (`group.` / `workspace.`) so a group header and a workspace row can /// never collide even though both wrap UUID-backed ids. public var id: String { switch self { - case .groupHeader(let group, _): + case .groupHeader(let group, _, _): return "group.\(group.id.rawValue)" - case .workspace(let workspace, _): + case .workspace(let workspace, _, _): return "workspace.\(workspace.id.rawValue)" } } @@ -38,21 +42,20 @@ public enum MobileWorkspaceListItem: Identifiable, Equatable, Sendable { /// Build the ordered list items from a workspace list and its groups. /// /// Mirrors `SidebarWorkspaceRenderItem.renderItems` on the Mac: - /// - Items follow `workspaces` order. A group header is emitted at the first - /// member's position. + /// - Items follow `workspaces` order. A group header is emitted at its anchor's + /// position within its parent. /// - The anchor workspace is never a separate row (the header represents it). - /// - When a group is collapsed, its members are skipped (header kept). + /// - When a group is collapsed, its descendants are skipped (header kept). /// - Ungrouped workspaces interleave inline by position. /// - /// A `groupID` referencing a group not present in `groups` (e.g. a transient - /// payload skew) degrades gracefully: the workspace renders as an ungrouped - /// row rather than vanishing. + /// A `groupID` referencing a group not present in `groups`, or a group whose + /// anchor is absent, degrades gracefully: the workspace renders as an + /// ungrouped row rather than vanishing. /// - /// Non-contiguous members of an already-emitted group (possible when the - /// Mac's spatial order interleaves another row between members) do not - /// re-emit the header: the stray member renders at its own position, still - /// indented to mark its membership, and is still hidden while its group is - /// collapsed. Membership is never silently dropped. + /// Non-contiguous members of an already-emitted expanded group stay at + /// their own spatial position, still indented to mark membership. A + /// collapsed group still hides those stray members and includes them in + /// the collapsed unread aggregate. /// /// - Parameters: /// - workspaces: The workspaces in the Mac's spatial order. @@ -64,55 +67,140 @@ public enum MobileWorkspaceListItem: Identifiable, Equatable, Sendable { ) -> [MobileWorkspaceListItem] { guard !workspaces.isEmpty else { return [] } let groupsByID = Dictionary(groups.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + let workspaceIDs = Set(workspaces.map(\.id)) + let renderableGroupIDs = Set(groupsByID.values.compactMap { + workspaceIDs.contains($0.anchorWorkspaceID) ? $0.id : nil + }) + var anchorGroupByWorkspaceID: [MobileWorkspacePreview.ID: MobileWorkspaceGroupPreview] = [:] + for group in groups where renderableGroupIDs.contains(group.id) && anchorGroupByWorkspaceID[group.anchorWorkspaceID] == nil { + anchorGroupByWorkspaceID[group.anchorWorkspaceID] = group + } + var parentGroupIDByGroupID: [MobileWorkspaceGroupPreview.ID: MobileWorkspaceGroupPreview.ID?] = [:] + for group in groups { + let parentID: MobileWorkspaceGroupPreview.ID? = { + guard let parentGroupID = group.parentGroupID, + parentGroupID != group.id, + renderableGroupIDs.contains(parentGroupID) else { + return nil + } + return parentGroupID + }() + parentGroupIDByGroupID[group.id] = parentID + } // Aggregate unread state per group up front (membership can be // non-contiguous, so this cannot be folded into the emit loop). // Mirrors the Mac header badge: anchor-only while expanded, whole - // group (anchor included) while collapsed. + // subtree (anchor included) while collapsed. var anchorUnreadByGroupID: [MobileWorkspaceGroupPreview.ID: Bool] = [:] - var anyMemberUnreadByGroupID: [MobileWorkspaceGroupPreview.ID: Bool] = [:] + var directUnreadByGroupID: [MobileWorkspaceGroupPreview.ID: Bool] = [:] + var childGroupsByParentID: [MobileWorkspaceGroupPreview.ID?: [MobileWorkspaceGroupPreview]] = [:] for workspace in workspaces { - guard let groupID = workspace.groupID, let group = groupsByID[groupID] else { continue } - anyMemberUnreadByGroupID[groupID, default: false] = anyMemberUnreadByGroupID[groupID, default: false] || workspace.hasUnread + guard let groupID = workspace.groupID, + let group = groupsByID[groupID], + renderableGroupIDs.contains(groupID) else { continue } + directUnreadByGroupID[groupID, default: false] = directUnreadByGroupID[groupID, default: false] || workspace.hasUnread if group.anchorWorkspaceID == workspace.id { anchorUnreadByGroupID[groupID] = workspace.hasUnread } } + for group in groups where renderableGroupIDs.contains(group.id) { + childGroupsByParentID[parentGroupIDByGroupID[group.id] ?? nil, default: []].append(group) + } + + var subtreeUnreadByGroupID: [MobileWorkspaceGroupPreview.ID: Bool] = [:] + func subtreeHasUnread(for groupID: MobileWorkspaceGroupPreview.ID, visiting: inout Set) -> Bool { + if let cached = subtreeUnreadByGroupID[groupID] { + return cached + } + guard visiting.insert(groupID).inserted else { + return directUnreadByGroupID[groupID, default: false] + } + var hasUnread = directUnreadByGroupID[groupID, default: false] + for childGroup in childGroupsByParentID[Optional(groupID)] ?? [] { + hasUnread = hasUnread || subtreeHasUnread(for: childGroup.id, visiting: &visiting) + } + visiting.remove(groupID) + subtreeUnreadByGroupID[groupID] = hasUnread + return hasUnread + } + + func normalizedParentGroupID(for groupID: MobileWorkspaceGroupPreview.ID) -> MobileWorkspaceGroupPreview.ID? { + parentGroupIDByGroupID[groupID] ?? nil + } + + var depthByGroupID: [MobileWorkspaceGroupPreview.ID: Int] = [:] + func groupDepth(for groupID: MobileWorkspaceGroupPreview.ID, visiting: inout Set) -> Int { + if let cached = depthByGroupID[groupID] { + return cached + } + guard visiting.insert(groupID).inserted else { return 0 } + defer { visiting.remove(groupID) } + guard let parentID = normalizedParentGroupID(for: groupID) else { + depthByGroupID[groupID] = 0 + return 0 + } + let depth = groupDepth(for: parentID, visiting: &visiting) + 1 + depthByGroupID[groupID] = depth + return depth + } var items: [MobileWorkspaceListItem] = [] items.reserveCapacity(workspaces.count + groups.count) - var lastEmittedGroupID: MobileWorkspaceGroupPreview.ID? var emittedHeaders: Set = [] var collapsedByGroupID: [MobileWorkspaceGroupPreview.ID: Bool] = [:] - for workspace in workspaces { - // Resolve the membership only when the referenced group actually - // exists; otherwise treat the workspace as ungrouped. - let groupID: MobileWorkspaceGroupPreview.ID? = workspace.groupID - .flatMap { groupsByID[$0] != nil ? $0 : nil } - - if groupID != lastEmittedGroupID { - lastEmittedGroupID = groupID - if let groupID, let group = groupsByID[groupID], !emittedHeaders.contains(groupID) { - let hasUnread = group.isCollapsed - ? anyMemberUnreadByGroupID[groupID, default: false] - : anchorUnreadByGroupID[groupID, default: false] - items.append(.groupHeader(group, hasUnread: hasUnread)) - emittedHeaders.insert(groupID) - collapsedByGroupID[groupID] = group.isCollapsed + func isHiddenByCollapsedAncestor(_ groupID: MobileWorkspaceGroupPreview.ID) -> Bool { + var visited: Set = [] + var cursor = normalizedParentGroupID(for: groupID) + while let parentID = cursor { + guard visited.insert(parentID).inserted else { return false } + if collapsedByGroupID[parentID] == true { + return true } + cursor = normalizedParentGroupID(for: parentID) } + return false + } + + func appendGroup(_ group: MobileWorkspaceGroupPreview) { + guard emittedHeaders.insert(group.id).inserted else { return } + var visiting: Set = [] + let hasUnread = group.isCollapsed + ? subtreeHasUnread(for: group.id, visiting: &visiting) + : anchorUnreadByGroupID[group.id, default: false] + var depthVisiting: Set = [] + let depth = groupDepth(for: group.id, visiting: &depthVisiting) + items.append(.groupHeader(group, hasUnread: hasUnread, depth: depth)) + collapsedByGroupID[group.id] = group.isCollapsed + } - if let groupID, let group = groupsByID[groupID], group.anchorWorkspaceID == workspace.id { - // Anchor is represented exclusively by the group header. + for workspace in workspaces { + guard let groupID = workspace.groupID, + groupsByID[groupID] != nil, + renderableGroupIDs.contains(groupID) else { + items.append(.workspace(workspace, indented: false)) continue } - let isCollapsed = groupID.map { collapsedByGroupID[$0] ?? false } ?? false - if groupID == nil || !isCollapsed { - items.append(.workspace(workspace, indented: groupID != nil)) + if isHiddenByCollapsedAncestor(groupID) { + continue } + + if let anchoredGroup = anchorGroupByWorkspaceID[workspace.id], + anchoredGroup.id == groupID { + appendGroup(anchoredGroup) + continue + } + + if collapsedByGroupID[groupID] == true { + continue + } + + var depthVisiting: Set = [] + items.append(.workspace(workspace, indented: true, depth: groupDepth(for: groupID, visiting: &depthVisiting))) } + return items } } diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceListItemTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceListItemTests.swift index 45987322b6e2..436def4dc40f 100644 --- a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceListItemTests.swift +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceListItemTests.swift @@ -22,6 +22,7 @@ import Testing anchor: String, collapsed: Bool = false, pinned: Bool = false, + parent: String? = nil, name: String? = nil ) -> MobileWorkspaceGroupPreview { MobileWorkspaceGroupPreview( @@ -29,6 +30,7 @@ import Testing name: name ?? id, isCollapsed: collapsed, isPinned: pinned, + parentGroupID: parent.map { .init(rawValue: $0) }, anchorWorkspaceID: .init(rawValue: anchor) ) } @@ -95,6 +97,14 @@ import Testing #expect(items == [.workspace(workspace("a", group: "missing"), indented: false)]) } + @Test func missingAnchorGroupDegradesMembersToUngroupedRows() { + let items = MobileWorkspaceListItem.items( + workspaces: [workspace("member", group: "g")], + groups: [group("g", anchor: "missing-anchor", collapsed: true)] + ) + #expect(items == [.workspace(workspace("member", group: "g"), indented: false)]) + } + @Test func anchorOnlyGroupRendersHeaderWithNoMembers() { let items = MobileWorkspaceListItem.items( workspaces: [workspace("a", group: "g")], @@ -177,4 +187,108 @@ import Testing .workspace(workspace("mid"), indented: false), ]) } + + @Test func expandedGroupKeepsNonContiguousMemberAtSpatialPosition() { + let items = MobileWorkspaceListItem.items( + workspaces: [ + workspace("a", group: "g"), + workspace("mid"), + workspace("b", group: "g"), + ], + groups: [group("g", anchor: "a")] + ) + + #expect(items == [ + .groupHeader(group("g", anchor: "a"), hasUnread: false), + .workspace(workspace("mid"), indented: false), + .workspace(workspace("b", group: "g"), indented: true), + ]) + } + + @Test func nestedGroupsRenderUnderExpandedParent() { + let items = MobileWorkspaceListItem.items( + workspaces: [ + workspace("hotels-anchor", group: "hotels"), + workspace("hotels-loose", group: "hotels"), + workspace("marriott-anchor", group: "marriott"), + workspace("terminal", group: "marriott"), + workspace("outside"), + ], + groups: [ + group("hotels", anchor: "hotels-anchor"), + group("marriott", anchor: "marriott-anchor", parent: "hotels"), + ] + ) + + #expect(items == [ + .groupHeader(group("hotels", anchor: "hotels-anchor"), hasUnread: false), + .workspace(workspace("hotels-loose", group: "hotels"), indented: true), + .groupHeader(group("marriott", anchor: "marriott-anchor", parent: "hotels"), hasUnread: false, depth: 1), + .workspace(workspace("terminal", group: "marriott"), indented: true, depth: 1), + .workspace(workspace("outside"), indented: false), + ]) + } + + @Test func collapsedParentGroupHidesDescendantGroupsAndRows() { + let items = MobileWorkspaceListItem.items( + workspaces: [ + workspace("hotels-anchor", group: "hotels"), + workspace("marriott-anchor", group: "marriott"), + workspace("terminal", group: "marriott", unread: true), + workspace("outside"), + ], + groups: [ + group("hotels", anchor: "hotels-anchor", collapsed: true), + group("marriott", anchor: "marriott-anchor", parent: "hotels"), + ] + ) + + #expect(items == [ + .groupHeader(group("hotels", anchor: "hotels-anchor", collapsed: true), hasUnread: true), + .workspace(workspace("outside"), indented: false), + ]) + } + + @Test func childGroupWithMissingParentAnchorPromotesToTopLevel() { + let items = MobileWorkspaceListItem.items( + workspaces: [ + workspace("parent-member", group: "parent"), + workspace("child-anchor", group: "child"), + workspace("child-member", group: "child"), + ], + groups: [ + group("parent", anchor: "missing-parent-anchor", collapsed: true), + group("child", anchor: "child-anchor", parent: "parent"), + ] + ) + + #expect(items == [ + .workspace(workspace("parent-member", group: "parent"), indented: false), + .groupHeader(group("child", anchor: "child-anchor", parent: "parent"), hasUnread: false), + .workspace(workspace("child-member", group: "child"), indented: true), + ]) + } + + @Test func collapsedChildGroupRemainsVisibleUnderExpandedParent() { + let items = MobileWorkspaceListItem.items( + workspaces: [ + workspace("hotels-anchor", group: "hotels"), + workspace("marriott-anchor", group: "marriott"), + workspace("terminal", group: "marriott", unread: true), + ], + groups: [ + group("hotels", anchor: "hotels-anchor"), + group("marriott", anchor: "marriott-anchor", collapsed: true, parent: "hotels"), + ] + ) + + #expect(items == [ + .groupHeader(group("hotels", anchor: "hotels-anchor"), hasUnread: false), + .groupHeader( + group("marriott", anchor: "marriott-anchor", collapsed: true, parent: "hotels"), + hasUnread: true, + depth: 1 + ), + ]) + } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 9c6cffb705cd..4a9ab680b1fd 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -22,9 +22,7 @@ struct WorkspaceDetailView: View { let createWorkspace: () -> Void let canCreateWorkspace: Bool let createTerminal: () -> Void - /// Close this workspace on the Mac. When `nil` (older Macs without the - /// `workspace.close.v1` capability, or previews) the close affordance is - /// hidden from the top-bar menu. Mirrors the workspace list's gating. + /// Close this workspace on the Mac; `nil` hides the top-bar close affordance. let closeWorkspace: ((MobileWorkspacePreview.ID) -> Void)? let reportTerminalViewport: (MobileWorkspacePreview.ID, MobileTerminalPreview.ID, MobileTerminalViewportSize) -> Void let sendTerminalInput: (String) -> Void @@ -42,8 +40,7 @@ struct WorkspaceDetailView: View { @State private var isSubmittingFeedback = false @State private var feedbackErrorMessage: String? @State private var isTextSheetPresented = false - /// Drives the rename-workspace dialog launched from the picker menu, and its - /// editable text (seeded with the current name when presented). + /// Rename-workspace dialog state seeded with the current name when presented. @State var isRenamePresented = false @State var renameText = "" /// Live pane width for capping the leading glass title pill. diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift index 4f5c462538ff..514028fe0f47 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift @@ -474,7 +474,7 @@ struct WorkspaceListView: View { private var groupedRows: some View { ForEach(groupedListItems) { item in switch item { - case .groupHeader(let group, let hasUnread): + case .groupHeader(let group, let hasUnread, let depth): WorkspaceGroupHeaderRow( group: group, hasUnread: hasUnread, @@ -485,16 +485,16 @@ struct WorkspaceListView: View { toggleCollapsed: toggleGroupCollapsed, unreadIndicatorLeftShift: unreadIndicatorLeftShift ) - .listRowInsets(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12)) + .listRowInsets(EdgeInsets(top: 6, leading: 12 + CGFloat(max(depth, 0)) * 20, bottom: 6, trailing: 12)) .listRowSeparator(.hidden) - case .workspace(let workspace, let indented): - workspaceRow(workspace, indented: indented) + case .workspace(let workspace, let indented, let depth): + workspaceRow(workspace, indented: indented, depth: depth) } } } @ViewBuilder - private func workspaceRow(_ workspace: MobileWorkspacePreview, indented: Bool) -> some View { + private func workspaceRow(_ workspace: MobileWorkspacePreview, indented: Bool, depth: Int = 0) -> some View { let capabilities = workspace.actionCapabilities WorkspaceNavigationRow( workspace: workspace, @@ -516,7 +516,7 @@ struct WorkspaceListView: View { confirmCloseWorkspace() } : nil ) - .listRowInsets(EdgeInsets(top: 4, leading: indented ? 32 : 12, bottom: 4, trailing: 12)) + .listRowInsets(EdgeInsets(top: 4, leading: indented ? 32 + CGFloat(max(depth, 0)) * 20 : 12, bottom: 4, trailing: 12)) .listRowSeparator(.hidden) } diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceGroup/ControlCommandCoordinator+WorkspaceGroup.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceGroup/ControlCommandCoordinator+WorkspaceGroup.swift index 97e67bc20133..6b1083fa1e54 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceGroup/ControlCommandCoordinator+WorkspaceGroup.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceGroup/ControlCommandCoordinator+WorkspaceGroup.swift @@ -66,6 +66,8 @@ extension ControlCommandCoordinator { "name": .string(group.name), "is_collapsed": .bool(group.isCollapsed), "is_pinned": .bool(group.isPinned), + "parent_group_id": orNull(group.parentGroupID?.uuidString), + "parent_group_ref": ref(.workspaceGroup, group.parentGroupID), "anchor_workspace_id": .string(group.anchorWorkspaceID.uuidString), "anchor_workspace_ref": ref(.workspace, group.anchorWorkspaceID), "custom_color": orNull(group.customColor), diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceGroup/ControlWorkspaceGroupSnapshot.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceGroup/ControlWorkspaceGroupSnapshot.swift index 8232a91d7168..771244b56c20 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceGroup/ControlWorkspaceGroupSnapshot.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceGroup/ControlWorkspaceGroupSnapshot.swift @@ -17,6 +17,8 @@ public struct ControlWorkspaceGroupSnapshot: Sendable, Equatable { public let isCollapsed: Bool /// Whether the group is pinned. public let isPinned: Bool + /// Parent workspace group identifier when this group is nested. + public let parentGroupID: UUID? /// The anchor workspace's identifier. public let anchorWorkspaceID: UUID /// The group's custom color override, if any. @@ -33,6 +35,7 @@ public struct ControlWorkspaceGroupSnapshot: Sendable, Equatable { /// - name: The group's display name. /// - isCollapsed: Whether the group is collapsed. /// - isPinned: Whether the group is pinned. + /// - parentGroupID: The parent workspace group identifier, if nested. /// - anchorWorkspaceID: The anchor workspace's identifier. /// - customColor: The custom color override, if any. /// - iconSymbol: The custom icon symbol, if any. @@ -42,6 +45,7 @@ public struct ControlWorkspaceGroupSnapshot: Sendable, Equatable { name: String, isCollapsed: Bool, isPinned: Bool, + parentGroupID: UUID? = nil, anchorWorkspaceID: UUID, customColor: String?, iconSymbol: String?, @@ -51,6 +55,7 @@ public struct ControlWorkspaceGroupSnapshot: Sendable, Equatable { self.name = name self.isCollapsed = isCollapsed self.isPinned = isPinned + self.parentGroupID = parentGroupID self.anchorWorkspaceID = anchorWorkspaceID self.customColor = customColor self.iconSymbol = iconSymbol diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropAction.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropAction.swift index 7d2e71f2b054..f8ee09ecd7ac 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropAction.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropAction.swift @@ -5,6 +5,9 @@ public enum SidebarWorkspaceReorderDropAction: Equatable, Sendable { /// Reorder a workspace already present in the destination sidebar. case reorder(targetIndex: Int, usesTopLevelRows: Bool, explicitGroupId: UUID?) + /// Move a workspace group under a new parent group. + case reparentGroup(groupId: UUID, parentGroupId: UUID) + /// Insert a workspace dragged from another window at the destination index. /// /// `insertionIndex` is clamped for the dragged workspace's pin state and diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropResolver+GroupLegalRange.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropResolver+GroupLegalRange.swift new file mode 100644 index 000000000000..5de056d209e1 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropResolver+GroupLegalRange.swift @@ -0,0 +1,203 @@ +import CoreGraphics +import Foundation + +extension SidebarWorkspaceReorderDropResolver { + func legalInsertionRange( + draggedWorkspace: SidebarWorkspaceReorderWorkspaceSnapshot, + explicitGroupId: UUID, + workspaces: [SidebarWorkspaceReorderWorkspaceSnapshot], + groupsById: [UUID: SidebarWorkspaceReorderGroupSnapshot] + ) -> ClosedRange? { + guard let group = groupsById[explicitGroupId], + draggedWorkspace.id != group.anchorWorkspaceId else { + return nil + } + let subtreeGroupIds = groupSubtreeIds(rootGroupId: explicitGroupId, groupsById: groupsById) + let groupByAnchorId = Dictionary(uniqueKeysWithValues: groupsById.values.map { ($0.anchorWorkspaceId, $0) }) + let memberIndices = workspaces.indices.filter { index in + workspaces[index].groupId.map { subtreeGroupIds.contains($0) } ?? false + } + guard let firstIndex = memberIndices.first, + let lastIndex = memberIndices.last else { + return nil + } + let pinnedMemberCount = memberIndices.reduce(into: 0) { count, index in + let member = workspaces[index] + if member.id != group.anchorWorkspaceId, + workspaceRowOccupiesPinnedParentSlot( + member, + explicitGroupId: explicitGroupId, + groupsById: groupsById, + groupByAnchorId: groupByAnchorId + ) { + count += 1 + } + } + if workspaceRowIsPinned(draggedWorkspace, groupByAnchorId: groupByAnchorId) { + let lower = min(firstIndex + 1, workspaces.count) + let upper = min(firstIndex + 1 + pinnedMemberCount, workspaces.count) + return lower...max(lower, upper) + } + let lower = min(firstIndex + 1 + pinnedMemberCount, workspaces.count) + let upper = min(lastIndex + 1, workspaces.count) + return min(lower, upper)...max(lower, upper) + } + + func renderedGroupScopedIndicator( + request: SidebarWorkspaceReorderDropRequest, + targetWorkspaceId: UUID, + targetIndicator: SidebarDropIndicator, + targetHeight: CGFloat?, + legalRange: ClosedRange?, + explicitGroupId: UUID, + groupsById: [UUID: SidebarWorkspaceReorderGroupSnapshot] + ) -> SidebarDropIndicator { + let tabIds = request.workspaces.map(\.id) + let pinnedTabIds = Set(request.workspaces.filter { $0.groupId == nil && $0.isPinned }.map(\.id)) + let planner = SidebarDropPlanner() + let renderedIndicator = planner.indicator( + draggedTabId: request.draggedWorkspaceId, + targetTabId: targetWorkspaceId, + tabIds: tabIds, + pinnedTabIds: pinnedTabIds, + legalInsertionRange: legalRange, + pointerY: pointerY(for: targetIndicator.edge, targetHeight: targetHeight), + targetHeight: targetHeight, + preserveTargetEdge: true + ) ?? planner.indicator( + draggedTabId: request.draggedWorkspaceId, + targetTabId: targetWorkspaceId, + tabIds: tabIds, + pinnedTabIds: pinnedTabIds, + legalInsertionRange: legalRange, + pointerY: pointerY(for: targetIndicator.edge, targetHeight: targetHeight), + targetHeight: targetHeight, + preserveTargetEdge: true, + suppressesNoOp: false + ) ?? targetIndicator + let workspacesById = Dictionary(uniqueKeysWithValues: request.workspaces.map { ($0.id, $0) }) + return renderedGroupScopedIndicator( + renderedIndicator, + explicitGroupId: explicitGroupId, + tabIds: tabIds, + workspacesById: workspacesById, + groupsById: groupsById + ) + } + + private func renderedGroupScopedIndicator( + _ indicator: SidebarDropIndicator, + explicitGroupId: UUID, + tabIds: [UUID], + workspacesById: [UUID: SidebarWorkspaceReorderWorkspaceSnapshot], + groupsById: [UUID: SidebarWorkspaceReorderGroupSnapshot] + ) -> SidebarDropIndicator { + let subtreeGroupIds = groupSubtreeIds(rootGroupId: explicitGroupId, groupsById: groupsById) + if let tabId = indicator.tabId, + workspaceIsInGroupSubtree(tabId, workspacesById: workspacesById, subtreeGroupIds: subtreeGroupIds) { + return indicator + } + + let insertion: Int + if let tabId = indicator.tabId, + let index = tabIds.firstIndex(of: tabId) { + insertion = indicator.edge == .bottom ? index + 1 : index + } else { + insertion = tabIds.count + } + let prefixEnd = min(max(insertion, 0), tabIds.count) + guard prefixEnd > 0 else { return indicator } + for tabId in tabIds[.. Set { + var childGroupIdsByParentId: [UUID: [UUID]] = [:] + for group in groupsById.values { + if let parentGroupId = group.parentGroupId { + childGroupIdsByParentId[parentGroupId, default: []].append(group.id) + } + } + var result: Set = [] + var stack = [rootGroupId] + while let current = stack.popLast() { + guard result.insert(current).inserted else { continue } + stack.append(contentsOf: childGroupIdsByParentId[current] ?? []) + } + return result + } + + func groupIsInSubtree( + _ groupId: UUID, + rootGroupId: UUID, + groupsById: [UUID: SidebarWorkspaceReorderGroupSnapshot] + ) -> Bool { + var currentGroupId: UUID? = groupId + var visited: Set = [] + while let current = currentGroupId, + let group = groupsById[current], + visited.insert(current).inserted { + if current == rootGroupId { + return true + } + currentGroupId = group.parentGroupId + } + return false + } + + private func workspaceRowOccupiesPinnedParentSlot( + _ workspace: SidebarWorkspaceReorderWorkspaceSnapshot, + explicitGroupId: UUID, + groupsById: [UUID: SidebarWorkspaceReorderGroupSnapshot], + groupByAnchorId: [UUID: SidebarWorkspaceReorderGroupSnapshot] + ) -> Bool { + if let directChildGroupId = directChildGroupId( + containing: workspace, + rootGroupId: explicitGroupId, + groupsById: groupsById + ) { + return groupsById[directChildGroupId]?.isPinned == true + } + return workspaceRowIsPinned(workspace, groupByAnchorId: groupByAnchorId) + } + + private func directChildGroupId( + containing workspace: SidebarWorkspaceReorderWorkspaceSnapshot, + rootGroupId: UUID, + groupsById: [UUID: SidebarWorkspaceReorderGroupSnapshot] + ) -> UUID? { + guard var currentGroupId = workspace.groupId else { return nil } + var visited: Set = [] + while let group = groupsById[currentGroupId], + let parentGroupId = group.parentGroupId { + guard visited.insert(currentGroupId).inserted else { return nil } + if parentGroupId == rootGroupId { + return currentGroupId + } + currentGroupId = parentGroupId + } + return nil + } + + private func workspaceIsInGroupSubtree( + _ workspaceId: UUID, + workspacesById: [UUID: SidebarWorkspaceReorderWorkspaceSnapshot], + subtreeGroupIds: Set + ) -> Bool { + guard let groupId = workspacesById[workspaceId]?.groupId else { return false } + return subtreeGroupIds.contains(groupId) + } + + private func workspaceRowIsPinned( + _ workspace: SidebarWorkspaceReorderWorkspaceSnapshot, + groupByAnchorId: [UUID: SidebarWorkspaceReorderGroupSnapshot] + ) -> Bool { + groupByAnchorId[workspace.id]?.isPinned ?? workspace.isPinned + } +} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropResolver+GroupReparent.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropResolver+GroupReparent.swift new file mode 100644 index 000000000000..3052573022f6 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropResolver+GroupReparent.swift @@ -0,0 +1,77 @@ +import CoreGraphics +import Foundation + +extension SidebarWorkspaceReorderDropResolver { + func groupReparentPlan( + request: SidebarWorkspaceReorderDropRequest, + context: SidebarWorkspaceReorderHitContext, + draggedWorkspace: SidebarWorkspaceReorderWorkspaceSnapshot, + groupsById: [UUID: SidebarWorkspaceReorderGroupSnapshot], + groupByAnchorId: [UUID: SidebarWorkspaceReorderGroupSnapshot] + ) -> SidebarWorkspaceReorderDropPlan? { + guard let target = context.target, + target.isGroupHeader, + let targetGroupId = target.groupId, + let targetGroup = groupsById[targetGroupId], + target.workspaceId == targetGroup.anchorWorkspaceId, + let draggedGroup = groupByAnchorId[draggedWorkspace.id], + draggedGroup.id != targetGroupId, + isCenterGroupHeaderDrop(pointerY: context.pointerY, targetHeight: context.targetHeight), + canReparent(groupId: draggedGroup.id, to: targetGroupId, groupsById: groupsById) else { + return nil + } + return SidebarWorkspaceReorderDropPlan( + draggedWorkspaceId: request.draggedWorkspaceId, + indicator: nil, + indicatorScope: .group(targetGroupId), + action: .reparentGroup(groupId: draggedGroup.id, parentGroupId: targetGroupId) + ) + } + + func pointerY(for edge: SidebarDropEdge, targetHeight: CGFloat?) -> CGFloat? { + guard let targetHeight else { return nil } + return edge == .top ? 0 : targetHeight + } + + func rootGroup( + containing groupId: UUID, + groupsById: [UUID: SidebarWorkspaceReorderGroupSnapshot] + ) -> SidebarWorkspaceReorderGroupSnapshot? { + guard var root = groupsById[groupId] else { return nil } + var visited: Set = [root.id] + while let parentId = root.parentGroupId, + let parent = groupsById[parentId], + visited.insert(parent.id).inserted { + root = parent + } + return root + } + + func isCenterGroupHeaderDrop(pointerY: CGFloat?, targetHeight: CGFloat?) -> Bool { + guard let pointerY, let targetHeight else { return false } + let height = max(targetHeight, 1) + let edgeBand = min(max(height * 0.25, 4), height * 0.4) + let y = min(max(pointerY, 0), height) + return y > edgeBand && y < height - edgeBand + } + + private func canReparent( + groupId: UUID, + to parentGroupId: UUID, + groupsById: [UUID: SidebarWorkspaceReorderGroupSnapshot] + ) -> Bool { + guard groupsById[groupId] != nil, + groupsById[parentGroupId] != nil, + groupId != parentGroupId else { + return false + } + var visited: Set = [] + var cursor: UUID? = parentGroupId + while let current = cursor { + guard visited.insert(current).inserted else { return false } + if current == groupId { return false } + cursor = groupsById[current]?.parentGroupId + } + return true + } +} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropResolver.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropResolver.swift index 9cad2626273b..7d48b5d1f63f 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropResolver.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropResolver.swift @@ -41,6 +41,10 @@ public struct SidebarWorkspaceReorderDropResolver: Sendable { ) } + if let reparentPlan = groupReparentPlan(request: request, context: context, draggedWorkspace: draggedWorkspace, groupsById: groupsById, groupByAnchorId: groupByAnchorId) { + return reparentPlan + } + if let groupId = explicitGroupId( request: request, context: context, @@ -130,13 +134,20 @@ public struct SidebarWorkspaceReorderDropResolver: Sendable { groupsById: [UUID: SidebarWorkspaceReorderGroupSnapshot], groupByAnchorId: [UUID: SidebarWorkspaceReorderGroupSnapshot] ) -> UUID? { - guard !groupByAnchorId.keys.contains(draggedWorkspace.id) else { return nil } guard let candidate = groupScopeCandidate( context: context, - groupsById: groupsById + groupsById: groupsById, + pointX: request.point.x, + isDraggedGroupAnchor: groupByAnchorId[draggedWorkspace.id] != nil ) else { return nil } + if let draggedGroup = groupByAnchorId[draggedWorkspace.id] { + guard candidate.groupId == draggedGroup.parentGroupId, + !groupIsInSubtree(candidate.groupId, rootGroupId: draggedGroup.id, groupsById: groupsById) else { + return nil + } + } guard candidate.isAmbiguous else { return candidate.groupId } // Ambiguous group/root dividers use a coarse hierarchy lane: the left // half of the sidebar means root, the right half means the group. @@ -157,9 +168,21 @@ public struct SidebarWorkspaceReorderDropResolver: Sendable { private func groupScopeCandidate( context: SidebarWorkspaceReorderHitContext, - groupsById: [UUID: SidebarWorkspaceReorderGroupSnapshot] + groupsById: [UUID: SidebarWorkspaceReorderGroupSnapshot], + pointX: CGFloat, + isDraggedGroupAnchor: Bool ) -> (groupId: UUID, isAmbiguous: Bool)? { if let target = context.target { + if target.isGroupHeader, + let groupId = target.groupId, + let parentGroupId = groupsById[groupId]?.parentGroupId, + groupsById[parentGroupId] != nil { + if !isDraggedGroupAnchor, + isCenterGroupHeaderDrop(pointerY: context.pointerY, targetHeight: context.targetHeight) { + return (groupId, false) + } + return (parentGroupId, false) + } if let groupId = target.groupId, groupsById[groupId] != nil { switch context.edge { @@ -167,8 +190,18 @@ public struct SidebarWorkspaceReorderDropResolver: Sendable { guard !target.isGroupHeader else { return nil } return (groupId, false) case .bottom: - let nextIsSameGroup = context.nextTarget?.groupId == groupId - return (groupId, !nextIsSameGroup) + let nextIsSameGroup = context.nextTarget?.groupId.map { nextGroupId in + nextGroupId == groupId || groupIsInSubtree(nextGroupId, rootGroupId: groupId, groupsById: groupsById) + } ?? false + if nextIsSameGroup { return (groupId, false) } + if let parentGroupId = groupsById[groupId]?.parentGroupId, + groupsById[parentGroupId] != nil { + guard pointX < target.frame.minX else { + return (groupId, false) + } + return (parentGroupId, false) + } + return (groupId, true) } } @@ -199,6 +232,7 @@ public struct SidebarWorkspaceReorderDropResolver: Sendable { guard let group = groupsById[explicitGroupId] else { return nil } let targetIndicator = groupScopedIndicator( context: context, + explicitGroupId: explicitGroupId, fallbackAnchorWorkspaceId: group.anchorWorkspaceId ) guard let targetWorkspaceId = targetIndicator.tabId else { return nil } @@ -210,7 +244,8 @@ public struct SidebarWorkspaceReorderDropResolver: Sendable { workspaces: request.workspaces, groupsById: groupsById ) - guard let targetIndex = SidebarDropPlanner().targetIndex( + let planner = SidebarDropPlanner() + guard let targetIndex = planner.targetIndex( draggedTabId: request.draggedWorkspaceId, targetTabId: targetWorkspaceId, indicator: targetIndicator, @@ -221,20 +256,17 @@ public struct SidebarWorkspaceReorderDropResolver: Sendable { return nil } - let renderedIndicator = SidebarDropPlanner().indicator( - draggedTabId: request.draggedWorkspaceId, - targetTabId: targetWorkspaceId, - tabIds: tabIds, - pinnedTabIds: pinnedTabIds, - legalInsertionRange: legalRange, - pointerY: pointerY(for: targetIndicator.edge, targetHeight: context.targetHeight), - targetHeight: context.targetHeight, - preserveTargetEdge: true - ) ?? targetIndicator - return SidebarWorkspaceReorderDropPlan( draggedWorkspaceId: request.draggedWorkspaceId, - indicator: renderedIndicator, + indicator: renderedGroupScopedIndicator( + request: request, + targetWorkspaceId: targetWorkspaceId, + targetIndicator: targetIndicator, + targetHeight: context.targetHeight, + legalRange: legalRange, + explicitGroupId: explicitGroupId, + groupsById: groupsById + ), indicatorScope: .group(explicitGroupId), action: .reorder( targetIndex: targetIndex, @@ -295,9 +327,9 @@ public struct SidebarWorkspaceReorderDropResolver: Sendable { return nil } + let draggedGroup = groupByAnchorId[draggedWorkspace.id] let promotesGroupedWorkspace = usesTopLevelRows && - draggedWorkspace.groupId != nil && - groupByAnchorId[draggedWorkspace.id] == nil + ((draggedWorkspace.groupId != nil && draggedGroup == nil) || draggedGroup?.parentGroupId != nil) let plannedIndicator = SidebarDropPlanner().indicator( draggedTabId: request.draggedWorkspaceId, targetTabId: rootTarget.workspaceId, @@ -387,6 +419,7 @@ public struct SidebarWorkspaceReorderDropResolver: Sendable { private func groupScopedIndicator( context: SidebarWorkspaceReorderHitContext, + explicitGroupId: UUID, fallbackAnchorWorkspaceId: UUID ) -> SidebarDropIndicator { if context.edge == .top, @@ -398,7 +431,8 @@ public struct SidebarWorkspaceReorderDropResolver: Sendable { } if let target = context.target { if target.isGroupHeader { - return SidebarDropIndicator(tabId: target.workspaceId, edge: .bottom) + let edge = target.groupId == explicitGroupId ? .bottom : context.edge + return SidebarDropIndicator(tabId: target.workspaceId, edge: edge) } return SidebarDropIndicator(tabId: target.workspaceId, edge: context.edge) } @@ -425,7 +459,7 @@ public struct SidebarWorkspaceReorderDropResolver: Sendable { indicatorScope: .topLevel ) } - if let groupId = target.groupId, + if let groupId = target.groupId.flatMap({ rootGroup(containing: $0, groupsById: groupsById)?.id }), let layout = groupLayoutsById[groupId] { let boundaryIndicator = groupBoundaryIndicator( context: context, @@ -464,10 +498,10 @@ public struct SidebarWorkspaceReorderDropResolver: Sendable { } let workspaceId: UUID if let groupId = target.groupId, - let group = groupsById[groupId] { + let group = rootGroup(containing: groupId, groupsById: groupsById) { workspaceId = group.anchorWorkspaceId } else if let groupId = workspacesById[target.workspaceId]?.groupId, - let group = groupsById[groupId] { + let group = rootGroup(containing: groupId, groupsById: groupsById) { workspaceId = group.anchorWorkspaceId } else { workspaceId = target.workspaceId @@ -541,7 +575,7 @@ public struct SidebarWorkspaceReorderDropResolver: Sendable { var anchorTargetByGroupId: [UUID: SidebarWorkspaceReorderDropTarget] = [:] var lastIndexByGroupId: [UUID: Int] = [:] for (index, target) in sortedTargets.enumerated() { - guard let groupId = target.groupId, + guard let groupId = target.groupId.flatMap({ rootGroup(containing: $0, groupsById: groupsById)?.id }), let group = groupsById[groupId] else { continue } @@ -556,7 +590,7 @@ public struct SidebarWorkspaceReorderDropResolver: Sendable { var nextRootTarget: SidebarWorkspaceReorderDropTarget? for index in sortedTargets.indices.reversed() { let target = sortedTargets[index] - if let groupId = target.groupId, + if let groupId = target.groupId.flatMap({ rootGroup(containing: $0, groupsById: groupsById)?.id }), lastIndexByGroupId[groupId] == index { nextRootTargetByGroupId[groupId] = nextRootTarget } @@ -580,37 +614,6 @@ public struct SidebarWorkspaceReorderDropResolver: Sendable { return layouts } - private func legalInsertionRange( - draggedWorkspace: SidebarWorkspaceReorderWorkspaceSnapshot, - explicitGroupId: UUID, - workspaces: [SidebarWorkspaceReorderWorkspaceSnapshot], - groupsById: [UUID: SidebarWorkspaceReorderGroupSnapshot] - ) -> ClosedRange? { - guard let group = groupsById[explicitGroupId], - draggedWorkspace.id != group.anchorWorkspaceId else { - return nil - } - let memberIndices = workspaces.indices.filter { workspaces[$0].groupId == explicitGroupId } - guard let firstIndex = memberIndices.first, - let lastIndex = memberIndices.last else { - return nil - } - let pinnedMemberCount = memberIndices.reduce(into: 0) { count, index in - let member = workspaces[index] - if member.id != group.anchorWorkspaceId, member.isPinned { - count += 1 - } - } - if draggedWorkspace.isPinned { - let lower = min(firstIndex + 1, workspaces.count) - let upper = min(firstIndex + 1 + pinnedMemberCount, workspaces.count) - return lower...max(lower, upper) - } - let lower = min(firstIndex + 1 + pinnedMemberCount, workspaces.count) - let upper = min(lastIndex + 1, workspaces.count) - return min(lower, upper)...max(lower, upper) - } - private func topLevelWorkspaceIds( workspaces: [SidebarWorkspaceReorderWorkspaceSnapshot], workspacesById: [UUID: SidebarWorkspaceReorderWorkspaceSnapshot], @@ -623,8 +626,8 @@ public struct SidebarWorkspaceReorderDropResolver: Sendable { ids.reserveCapacity(workspaces.count) for workspace in workspaces { if let groupId = workspace.groupId, - let group = groupsById[groupId] { - if emittedGroupIds.insert(groupId).inserted { + let group = rootGroup(containing: groupId, groupsById: groupsById) { + if emittedGroupIds.insert(group.id).inserted { ids.append(group.anchorWorkspaceId) } } else { @@ -635,7 +638,7 @@ public struct SidebarWorkspaceReorderDropResolver: Sendable { !ids.contains(promotingWorkspaceId), let promoted = workspaces.first(where: { $0.id == promotingWorkspaceId }), let groupId = promoted.groupId, - let group = groupsById[groupId], + let group = rootGroup(containing: groupId, groupsById: groupsById), let groupIndex = ids.firstIndex(of: group.anchorWorkspaceId) { ids.insert( promotingWorkspaceId, @@ -696,10 +699,6 @@ public struct SidebarWorkspaceReorderDropResolver: Sendable { return workspacesById[id]?.isPinned == true } - private func pointerY(for edge: SidebarDropEdge, targetHeight: CGFloat?) -> CGFloat? { - guard let targetHeight else { return nil } - return edge == .top ? 0 : targetHeight - } } private extension CGRect { diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderGroupSnapshot.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderGroupSnapshot.swift index 86b3679428b4..9725f1094dbc 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderGroupSnapshot.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderGroupSnapshot.swift @@ -11,15 +11,20 @@ public struct SidebarWorkspaceReorderGroupSnapshot: Equatable, Sendable { /// Whether the group belongs to the leading pinned tier. public let isPinned: Bool + /// Parent group containing this group, or `nil` for a root-level group. + public let parentGroupId: UUID? + /// Creates a workspace group snapshot for sidebar reorder planning. /// /// - Parameters: /// - id: The group identifier. /// - anchorWorkspaceId: The workspace id rendered as the group's header row. /// - isPinned: Whether the group belongs to the leading pinned tier. - public init(id: UUID, anchorWorkspaceId: UUID, isPinned: Bool) { + /// - parentGroupId: Parent group containing this group, or `nil` for a root-level group. + public init(id: UUID, anchorWorkspaceId: UUID, isPinned: Bool, parentGroupId: UUID? = nil) { self.id = id self.anchorWorkspaceId = anchorWorkspaceId self.isPinned = isPinned + self.parentGroupId = parentGroupId } } diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SidebarDropLegalRangeTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SidebarDropLegalRangeTests.swift new file mode 100644 index 000000000000..365c3fd54db6 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SidebarDropLegalRangeTests.swift @@ -0,0 +1,73 @@ +import CoreGraphics +import Foundation +import Testing + +@testable import CmuxFoundation + +@Suite struct SidebarDropLegalRangeTests { + @Test func unpinnedWorkspaceDropClampsAfterPinnedChildFolderSubtree() throws { + let parentGroupId = UUID() + let childGroupId = UUID() + let parentAnchor = UUID() + let childAnchor = UUID() + let childMember = UUID() + let dragged = UUID() + + let plan = try #require(SidebarWorkspaceReorderDropResolver().plan( + for: SidebarWorkspaceReorderDropRequest( + point: CGPoint(x: 20, y: 44), + draggedWorkspaceId: dragged, + workspaces: [ + SidebarWorkspaceReorderWorkspaceSnapshot(id: parentAnchor, isPinned: false, groupId: parentGroupId), + SidebarWorkspaceReorderWorkspaceSnapshot(id: childAnchor, isPinned: false, groupId: childGroupId), + SidebarWorkspaceReorderWorkspaceSnapshot(id: childMember, isPinned: false, groupId: childGroupId), + SidebarWorkspaceReorderWorkspaceSnapshot(id: dragged, isPinned: false, groupId: nil), + ], + groups: [ + SidebarWorkspaceReorderGroupSnapshot(id: parentGroupId, anchorWorkspaceId: parentAnchor, isPinned: false), + SidebarWorkspaceReorderGroupSnapshot( + id: childGroupId, + anchorWorkspaceId: childAnchor, + isPinned: true, + parentGroupId: parentGroupId + ), + ], + targets: [ + SidebarWorkspaceReorderDropTarget( + workspaceId: parentAnchor, + groupId: parentGroupId, + isGroupHeader: true, + frame: CGRect(x: 0, y: 0, width: 180, height: 32) + ), + SidebarWorkspaceReorderDropTarget( + workspaceId: childAnchor, + groupId: childGroupId, + isGroupHeader: true, + frame: CGRect(x: 12, y: 40, width: 168, height: 32) + ), + SidebarWorkspaceReorderDropTarget( + workspaceId: childMember, + groupId: childGroupId, + isGroupHeader: false, + frame: CGRect(x: 24, y: 80, width: 156, height: 32) + ), + SidebarWorkspaceReorderDropTarget( + workspaceId: dragged, + groupId: nil, + isGroupHeader: false, + frame: CGRect(x: 0, y: 120, width: 180, height: 32) + ), + ] + ) + )) + + #expect(plan.indicator == SidebarDropIndicator(tabId: childMember, edge: .bottom)) + guard case .reorder(let targetIndex, let usesTopLevelRows, let explicitGroupId) = plan.action else { + Issue.record("Expected parent-group reorder plan") + return + } + #expect(targetIndex == 3) + #expect(!usesTopLevelRows) + #expect(explicitGroupId == parentGroupId) + } +} diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SidebarDropNestedBoundaryTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SidebarDropNestedBoundaryTests.swift new file mode 100644 index 000000000000..0486f3092930 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SidebarDropNestedBoundaryTests.swift @@ -0,0 +1,206 @@ +import CoreGraphics +import Foundation +import Testing + +@testable import CmuxFoundation + +@Suite struct SidebarDropNestedBoundaryTests { + @Test func childFolderMemberBottomEdgeOutdentTargetsParentGroupSiblingScope() throws { + let parentGroupId = UUID() + let childGroupId = UUID() + let parentAnchor = UUID() + let dragged = UUID() + let childAnchor = UUID() + let childMember = UUID() + let outside = UUID() + + let plan = try #require(SidebarWorkspaceReorderDropResolver().plan( + for: childFolderBoundaryRequest( + point: CGPoint(x: 20, y: 108), + parentGroupId: parentGroupId, + childGroupId: childGroupId, + parentAnchor: parentAnchor, + dragged: dragged, + childAnchor: childAnchor, + childMember: childMember, + outside: outside + ) + )) + + #expect(plan.indicator == SidebarDropIndicator(tabId: childMember, edge: .bottom)) + #expect(plan.indicatorScope == .group(parentGroupId)) + guard case .reorder(let targetIndex, let usesTopLevelRows, let explicitGroupId) = plan.action else { + Issue.record("Expected parent-group reorder plan") + return + } + #expect(targetIndex == 3) + #expect(!usesTopLevelRows) + #expect(explicitGroupId == parentGroupId) + } + + @Test func childFolderMemberBottomEdgeAtChildIndentStaysInChildFolder() throws { + let parentGroupId = UUID() + let childGroupId = UUID() + let parentAnchor = UUID() + let dragged = UUID() + let childAnchor = UUID() + let childMember = UUID() + let outside = UUID() + + let plan = try #require(SidebarWorkspaceReorderDropResolver().plan( + for: childFolderBoundaryRequest( + point: CGPoint(x: 120, y: 108), + parentGroupId: parentGroupId, + childGroupId: childGroupId, + parentAnchor: parentAnchor, + dragged: dragged, + childAnchor: childAnchor, + childMember: childMember, + outside: outside + ) + )) + + #expect(plan.indicator == SidebarDropIndicator(tabId: childMember, edge: .bottom)) + #expect(plan.indicatorScope == .group(childGroupId)) + guard case .reorder(let targetIndex, let usesTopLevelRows, let explicitGroupId) = plan.action else { + Issue.record("Expected child-group reorder plan") + return + } + #expect(targetIndex == 3) + #expect(!usesTopLevelRows) + #expect(explicitGroupId == childGroupId) + } + + @Test func parentRowBottomEdgeBeforeChildHeaderStaysInParentFolder() throws { + let parentGroupId = UUID() + let childGroupId = UUID() + let parentAnchor = UUID() + let parentMember = UUID() + let childAnchor = UUID() + let childMember = UUID() + let dragged = UUID() + + let plan = try #require(SidebarWorkspaceReorderDropResolver().plan( + for: SidebarWorkspaceReorderDropRequest( + point: CGPoint(x: 0, y: 68), + draggedWorkspaceId: dragged, + workspaces: [ + SidebarWorkspaceReorderWorkspaceSnapshot(id: parentAnchor, isPinned: false, groupId: parentGroupId), + SidebarWorkspaceReorderWorkspaceSnapshot(id: parentMember, isPinned: false, groupId: parentGroupId), + SidebarWorkspaceReorderWorkspaceSnapshot(id: childAnchor, isPinned: false, groupId: childGroupId), + SidebarWorkspaceReorderWorkspaceSnapshot(id: childMember, isPinned: false, groupId: childGroupId), + SidebarWorkspaceReorderWorkspaceSnapshot(id: dragged, isPinned: false, groupId: nil), + ], + groups: [ + SidebarWorkspaceReorderGroupSnapshot(id: parentGroupId, anchorWorkspaceId: parentAnchor, isPinned: false), + SidebarWorkspaceReorderGroupSnapshot( + id: childGroupId, + anchorWorkspaceId: childAnchor, + isPinned: false, + parentGroupId: parentGroupId + ), + ], + targets: [ + SidebarWorkspaceReorderDropTarget( + workspaceId: parentAnchor, + groupId: parentGroupId, + isGroupHeader: true, + frame: CGRect(x: 0, y: 0, width: 180, height: 32) + ), + SidebarWorkspaceReorderDropTarget( + workspaceId: parentMember, + groupId: parentGroupId, + isGroupHeader: false, + frame: CGRect(x: 12, y: 40, width: 168, height: 32) + ), + SidebarWorkspaceReorderDropTarget( + workspaceId: childAnchor, + groupId: childGroupId, + isGroupHeader: true, + frame: CGRect(x: 12, y: 80, width: 168, height: 32) + ), + SidebarWorkspaceReorderDropTarget( + workspaceId: childMember, + groupId: childGroupId, + isGroupHeader: false, + frame: CGRect(x: 24, y: 120, width: 156, height: 32) + ), + SidebarWorkspaceReorderDropTarget( + workspaceId: dragged, + groupId: nil, + isGroupHeader: false, + frame: CGRect(x: 0, y: 160, width: 180, height: 32) + ), + ] + ) + )) + + #expect(plan.indicator == SidebarDropIndicator(tabId: parentMember, edge: .bottom)) + #expect(plan.indicatorScope == .group(parentGroupId)) + guard case .reorder(let targetIndex, let usesTopLevelRows, let explicitGroupId) = plan.action else { + Issue.record("Expected parent-group reorder plan") + return + } + #expect(targetIndex == 2) + #expect(!usesTopLevelRows) + #expect(explicitGroupId == parentGroupId) + } + + private func childFolderBoundaryRequest( + point: CGPoint, + parentGroupId: UUID, + childGroupId: UUID, + parentAnchor: UUID, + dragged: UUID, + childAnchor: UUID, + childMember: UUID, + outside: UUID + ) -> SidebarWorkspaceReorderDropRequest { + SidebarWorkspaceReorderDropRequest( + point: point, + draggedWorkspaceId: dragged, + workspaces: [ + SidebarWorkspaceReorderWorkspaceSnapshot(id: parentAnchor, isPinned: false, groupId: parentGroupId), + SidebarWorkspaceReorderWorkspaceSnapshot(id: dragged, isPinned: false, groupId: parentGroupId), + SidebarWorkspaceReorderWorkspaceSnapshot(id: childAnchor, isPinned: false, groupId: childGroupId), + SidebarWorkspaceReorderWorkspaceSnapshot(id: childMember, isPinned: false, groupId: childGroupId), + SidebarWorkspaceReorderWorkspaceSnapshot(id: outside, isPinned: false, groupId: nil), + ], + groups: [ + SidebarWorkspaceReorderGroupSnapshot(id: parentGroupId, anchorWorkspaceId: parentAnchor, isPinned: false), + SidebarWorkspaceReorderGroupSnapshot( + id: childGroupId, + anchorWorkspaceId: childAnchor, + isPinned: false, + parentGroupId: parentGroupId + ), + ], + targets: [ + SidebarWorkspaceReorderDropTarget( + workspaceId: parentAnchor, + groupId: parentGroupId, + isGroupHeader: true, + frame: CGRect(x: 0, y: 0, width: 180, height: 32) + ), + SidebarWorkspaceReorderDropTarget( + workspaceId: childAnchor, + groupId: childGroupId, + isGroupHeader: true, + frame: CGRect(x: 12, y: 40, width: 168, height: 32) + ), + SidebarWorkspaceReorderDropTarget( + workspaceId: childMember, + groupId: childGroupId, + isGroupHeader: false, + frame: CGRect(x: 24, y: 80, width: 156, height: 32) + ), + SidebarWorkspaceReorderDropTarget( + workspaceId: outside, + groupId: nil, + isGroupHeader: false, + frame: CGRect(x: 0, y: 120, width: 180, height: 32) + ), + ] + ) + } +} diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SidebarDropPlannerTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SidebarDropPlannerTests.swift index b114d497afeb..320b7db1738c 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SidebarDropPlannerTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SidebarDropPlannerTests.swift @@ -31,4 +31,463 @@ import Testing planner.workspaceAction(for: point, targets: targets) ) } + + @Test func ungroupedWorkspaceEdgeDropOverNestedGroupHeaderTargetsParentGroup() throws { + let rootGroupId = UUID() + let childGroupId = UUID() + let rootAnchor = UUID() + let childAnchor = UUID() + let childMember = UUID() + let dragged = UUID() + let outside = UUID() + + func plan(at point: CGPoint) throws -> SidebarWorkspaceReorderDropPlan { + try #require(SidebarWorkspaceReorderDropResolver().plan( + for: SidebarWorkspaceReorderDropRequest( + point: point, + draggedWorkspaceId: dragged, + workspaces: [ + SidebarWorkspaceReorderWorkspaceSnapshot(id: rootAnchor, isPinned: false, groupId: rootGroupId), + SidebarWorkspaceReorderWorkspaceSnapshot(id: childAnchor, isPinned: false, groupId: childGroupId), + SidebarWorkspaceReorderWorkspaceSnapshot(id: childMember, isPinned: false, groupId: childGroupId), + SidebarWorkspaceReorderWorkspaceSnapshot(id: dragged, isPinned: false, groupId: nil), + SidebarWorkspaceReorderWorkspaceSnapshot(id: outside, isPinned: false, groupId: nil), + ], + groups: [ + SidebarWorkspaceReorderGroupSnapshot(id: rootGroupId, anchorWorkspaceId: rootAnchor, isPinned: false), + SidebarWorkspaceReorderGroupSnapshot( + id: childGroupId, + anchorWorkspaceId: childAnchor, + isPinned: false, + parentGroupId: rootGroupId + ), + ], + targets: [ + SidebarWorkspaceReorderDropTarget( + workspaceId: rootAnchor, + groupId: rootGroupId, + isGroupHeader: true, + frame: CGRect(x: 0, y: 0, width: 180, height: 32) + ), + SidebarWorkspaceReorderDropTarget( + workspaceId: childAnchor, + groupId: childGroupId, + isGroupHeader: true, + frame: CGRect(x: 12, y: 40, width: 168, height: 32) + ), + SidebarWorkspaceReorderDropTarget( + workspaceId: childMember, + groupId: childGroupId, + isGroupHeader: false, + frame: CGRect(x: 24, y: 80, width: 156, height: 32) + ), + SidebarWorkspaceReorderDropTarget( + workspaceId: dragged, + groupId: nil, + isGroupHeader: false, + frame: CGRect(x: 0, y: 120, width: 180, height: 32) + ), + SidebarWorkspaceReorderDropTarget( + workspaceId: outside, + groupId: nil, + isGroupHeader: false, + frame: CGRect(x: 0, y: 160, width: 180, height: 32) + ), + ] + ) + )) + } + + let edgePlan = try plan(at: CGPoint(x: 12, y: 44)) + + #expect(edgePlan.indicator == SidebarDropIndicator(tabId: childAnchor, edge: .top)) + #expect(edgePlan.indicatorScope == .group(rootGroupId)) + guard case .reorder(let edgeTargetIndex, let edgeUsesTopLevelRows, let edgeExplicitGroupId) = edgePlan.action else { + Issue.record("Expected parent-group reorder plan") + return + } + #expect(edgeTargetIndex == 1) + #expect(!edgeUsesTopLevelRows) + #expect(edgeExplicitGroupId == rootGroupId) + + let centerPlan = try plan(at: CGPoint(x: 12, y: 56)) + + #expect(centerPlan.indicator == SidebarDropIndicator(tabId: childAnchor, edge: .bottom)) + #expect(centerPlan.indicatorScope == .group(childGroupId)) + guard case .reorder(let centerTargetIndex, let centerUsesTopLevelRows, let centerExplicitGroupId) = centerPlan.action else { + Issue.record("Expected child-group reorder plan") + return + } + #expect(centerTargetIndex == 2) + #expect(!centerUsesTopLevelRows) + #expect(centerExplicitGroupId == childGroupId) + } + + @Test func draggingNestedGroupAnchorToRootEndProducesPromotionPlan() throws { + let parentGroupId = UUID() + let childGroupId = UUID() + let parentAnchor = UUID() + let childAnchor = UUID() + + let plan = try #require(SidebarWorkspaceReorderDropResolver().plan( + for: SidebarWorkspaceReorderDropRequest( + point: CGPoint(x: 0, y: 96), + draggedWorkspaceId: childAnchor, + workspaces: [ + SidebarWorkspaceReorderWorkspaceSnapshot(id: parentAnchor, isPinned: false, groupId: parentGroupId), + SidebarWorkspaceReorderWorkspaceSnapshot(id: childAnchor, isPinned: false, groupId: childGroupId), + ], + groups: [ + SidebarWorkspaceReorderGroupSnapshot(id: parentGroupId, anchorWorkspaceId: parentAnchor, isPinned: false), + SidebarWorkspaceReorderGroupSnapshot( + id: childGroupId, + anchorWorkspaceId: childAnchor, + isPinned: false, + parentGroupId: parentGroupId + ), + ], + targets: [ + SidebarWorkspaceReorderDropTarget( + workspaceId: parentAnchor, + groupId: parentGroupId, + isGroupHeader: true, + frame: CGRect(x: 0, y: 0, width: 180, height: 32) + ), + SidebarWorkspaceReorderDropTarget( + workspaceId: childAnchor, + groupId: childGroupId, + isGroupHeader: true, + frame: CGRect(x: 12, y: 40, width: 168, height: 32) + ), + ] + ) + )) + + #expect(plan.indicator == SidebarDropIndicator(tabId: nil, edge: .bottom)) + #expect(plan.indicatorScope == .topLevel) + guard case .reorder(let targetIndex, let usesTopLevelRows, let explicitGroupId) = plan.action else { + Issue.record("Expected top-level reorder plan") + return + } + #expect(targetIndex == 1) + #expect(usesTopLevelRows) + #expect(explicitGroupId == nil) + } + + @Test func nestedChildFolderTopEdgeTargetsParentGroupSiblingScope() throws { + let parentGroupId = UUID() + let childGroupId = UUID() + let parentAnchor = UUID() + let childAnchor = UUID() + let childMember = UUID() + let dragged = UUID() + let outside = UUID() + + let plan = try #require(SidebarWorkspaceReorderDropResolver().plan( + for: nestedFolderRequest( + point: CGPoint(x: 20, y: 44), + parentGroupId: parentGroupId, + childGroupId: childGroupId, + parentAnchor: parentAnchor, + childAnchor: childAnchor, + childMember: childMember, + dragged: dragged, + outside: outside, + workspaces: [ + SidebarWorkspaceReorderWorkspaceSnapshot(id: parentAnchor, isPinned: false, groupId: parentGroupId), + SidebarWorkspaceReorderWorkspaceSnapshot(id: childAnchor, isPinned: false, groupId: childGroupId), + SidebarWorkspaceReorderWorkspaceSnapshot(id: childMember, isPinned: false, groupId: childGroupId), + SidebarWorkspaceReorderWorkspaceSnapshot(id: dragged, isPinned: false, groupId: parentGroupId), + SidebarWorkspaceReorderWorkspaceSnapshot(id: outside, isPinned: false, groupId: nil), + ] + ) + )) + + #expect(plan.indicator == SidebarDropIndicator(tabId: childAnchor, edge: .top)) + #expect(plan.indicatorScope == .group(parentGroupId)) + guard case .reorder(let targetIndex, let usesTopLevelRows, let explicitGroupId) = plan.action else { + Issue.record("Expected parent-group reorder plan") + return + } + #expect(targetIndex == 1) + #expect(!usesTopLevelRows) + #expect(explicitGroupId == parentGroupId) + } + + @Test func nestedChildFolderBottomEdgeTargetsParentGroupSiblingScope() throws { + let parentGroupId = UUID() + let childGroupId = UUID() + let parentAnchor = UUID() + let childAnchor = UUID() + let childMember = UUID() + let dragged = UUID() + let outside = UUID() + + let plan = try #require(SidebarWorkspaceReorderDropResolver().plan( + for: nestedFolderRequest( + point: CGPoint(x: 120, y: 68), + parentGroupId: parentGroupId, + childGroupId: childGroupId, + parentAnchor: parentAnchor, + childAnchor: childAnchor, + childMember: childMember, + dragged: dragged, + outside: outside, + workspaces: [ + SidebarWorkspaceReorderWorkspaceSnapshot(id: parentAnchor, isPinned: false, groupId: parentGroupId), + SidebarWorkspaceReorderWorkspaceSnapshot(id: dragged, isPinned: false, groupId: parentGroupId), + SidebarWorkspaceReorderWorkspaceSnapshot(id: childAnchor, isPinned: false, groupId: childGroupId), + SidebarWorkspaceReorderWorkspaceSnapshot(id: childMember, isPinned: false, groupId: childGroupId), + SidebarWorkspaceReorderWorkspaceSnapshot(id: outside, isPinned: false, groupId: nil), + ] + ) + )) + + #expect(plan.indicator == SidebarDropIndicator(tabId: childAnchor, edge: .bottom)) + #expect(plan.indicatorScope == .group(parentGroupId)) + guard case .reorder(let targetIndex, let usesTopLevelRows, let explicitGroupId) = plan.action else { + Issue.record("Expected parent-group reorder plan") + return + } + #expect(targetIndex == 2) + #expect(!usesTopLevelRows) + #expect(explicitGroupId == parentGroupId) + } + + @Test func nestedFolderAnchorEdgesTargetParentGroupSiblingScope() throws { + let parentGroupId = UUID() + let draggedGroupId = UUID() + let siblingGroupId = UUID() + let parentAnchor = UUID() + let draggedAnchor = UUID() + let draggedMember = UUID() + let siblingAnchor = UUID() + let siblingMember = UUID() + let outside = UUID() + + func request(point: CGPoint, draggedWorkspaceId: UUID) -> SidebarWorkspaceReorderDropRequest { + SidebarWorkspaceReorderDropRequest( + point: point, + draggedWorkspaceId: draggedWorkspaceId, + workspaces: [ + SidebarWorkspaceReorderWorkspaceSnapshot(id: parentAnchor, isPinned: false, groupId: parentGroupId), + SidebarWorkspaceReorderWorkspaceSnapshot(id: draggedAnchor, isPinned: false, groupId: draggedGroupId), + SidebarWorkspaceReorderWorkspaceSnapshot(id: draggedMember, isPinned: false, groupId: draggedGroupId), + SidebarWorkspaceReorderWorkspaceSnapshot(id: siblingAnchor, isPinned: false, groupId: siblingGroupId), + SidebarWorkspaceReorderWorkspaceSnapshot(id: siblingMember, isPinned: false, groupId: siblingGroupId), + SidebarWorkspaceReorderWorkspaceSnapshot(id: outside, isPinned: false, groupId: nil), + ], + groups: [ + SidebarWorkspaceReorderGroupSnapshot(id: parentGroupId, anchorWorkspaceId: parentAnchor, isPinned: false), + SidebarWorkspaceReorderGroupSnapshot( + id: draggedGroupId, + anchorWorkspaceId: draggedAnchor, + isPinned: false, + parentGroupId: parentGroupId + ), + SidebarWorkspaceReorderGroupSnapshot( + id: siblingGroupId, + anchorWorkspaceId: siblingAnchor, + isPinned: false, + parentGroupId: parentGroupId + ), + ], + targets: [ + SidebarWorkspaceReorderDropTarget( + workspaceId: parentAnchor, + groupId: parentGroupId, + isGroupHeader: true, + frame: CGRect(x: 0, y: 0, width: 180, height: 32) + ), + SidebarWorkspaceReorderDropTarget( + workspaceId: draggedAnchor, + groupId: draggedGroupId, + isGroupHeader: true, + frame: CGRect(x: 12, y: 40, width: 168, height: 32) + ), + SidebarWorkspaceReorderDropTarget( + workspaceId: draggedMember, + groupId: draggedGroupId, + isGroupHeader: false, + frame: CGRect(x: 24, y: 80, width: 156, height: 32) + ), + SidebarWorkspaceReorderDropTarget( + workspaceId: siblingAnchor, + groupId: siblingGroupId, + isGroupHeader: true, + frame: CGRect(x: 12, y: 120, width: 168, height: 32) + ), + SidebarWorkspaceReorderDropTarget( + workspaceId: siblingMember, + groupId: siblingGroupId, + isGroupHeader: false, + frame: CGRect(x: 24, y: 160, width: 156, height: 32) + ), + SidebarWorkspaceReorderDropTarget( + workspaceId: outside, + groupId: nil, + isGroupHeader: false, + frame: CGRect(x: 0, y: 200, width: 180, height: 32) + ), + ] + ) + } + + let topPlan = try #require(SidebarWorkspaceReorderDropResolver().plan( + for: request(point: CGPoint(x: 20, y: 124), draggedWorkspaceId: draggedAnchor) + )) + #expect(topPlan.indicator == SidebarDropIndicator(tabId: siblingAnchor, edge: .top)) + assertParentGroupPlan(topPlan, parentGroupId: parentGroupId, targetIndex: 2) + + let bottomPlan = try #require(SidebarWorkspaceReorderDropResolver().plan( + for: request(point: CGPoint(x: 20, y: 148), draggedWorkspaceId: draggedAnchor) + )) + #expect(bottomPlan.indicator == SidebarDropIndicator(tabId: siblingAnchor, edge: .bottom)) + assertParentGroupPlan(bottomPlan, parentGroupId: parentGroupId, targetIndex: 3) + + let memberPlan = try #require(SidebarWorkspaceReorderDropResolver().plan( + for: request(point: CGPoint(x: 20, y: 124), draggedWorkspaceId: draggedMember) + )) + #expect(memberPlan.indicator == SidebarDropIndicator(tabId: siblingAnchor, edge: .top)) + assertParentGroupPlan(memberPlan, parentGroupId: parentGroupId, targetIndex: 2) + } + + @Test func nestedFolderAnchorEdgeAcrossParentsDoesNotTargetOtherParentGroup() throws { + let sourceParentId = UUID() + let draggedGroupId = UUID() + let targetParentId = UUID() + let targetGroupId = UUID() + let sourceParentAnchor = UUID() + let draggedAnchor = UUID() + let targetParentAnchor = UUID() + let targetAnchor = UUID() + + let plan = try #require(SidebarWorkspaceReorderDropResolver().plan( + for: SidebarWorkspaceReorderDropRequest( + point: CGPoint(x: 20, y: 124), + draggedWorkspaceId: draggedAnchor, + workspaces: [ + SidebarWorkspaceReorderWorkspaceSnapshot(id: sourceParentAnchor, isPinned: false, groupId: sourceParentId), + SidebarWorkspaceReorderWorkspaceSnapshot(id: draggedAnchor, isPinned: false, groupId: draggedGroupId), + SidebarWorkspaceReorderWorkspaceSnapshot(id: targetParentAnchor, isPinned: false, groupId: targetParentId), + SidebarWorkspaceReorderWorkspaceSnapshot(id: targetAnchor, isPinned: false, groupId: targetGroupId), + ], + groups: [ + SidebarWorkspaceReorderGroupSnapshot(id: sourceParentId, anchorWorkspaceId: sourceParentAnchor, isPinned: false), + SidebarWorkspaceReorderGroupSnapshot( + id: draggedGroupId, + anchorWorkspaceId: draggedAnchor, + isPinned: false, + parentGroupId: sourceParentId + ), + SidebarWorkspaceReorderGroupSnapshot(id: targetParentId, anchorWorkspaceId: targetParentAnchor, isPinned: false), + SidebarWorkspaceReorderGroupSnapshot( + id: targetGroupId, + anchorWorkspaceId: targetAnchor, + isPinned: false, + parentGroupId: targetParentId + ), + ], + targets: [ + SidebarWorkspaceReorderDropTarget( + workspaceId: sourceParentAnchor, + groupId: sourceParentId, + isGroupHeader: true, + frame: CGRect(x: 0, y: 0, width: 180, height: 32) + ), + SidebarWorkspaceReorderDropTarget( + workspaceId: draggedAnchor, + groupId: draggedGroupId, + isGroupHeader: true, + frame: CGRect(x: 12, y: 40, width: 168, height: 32) + ), + SidebarWorkspaceReorderDropTarget( + workspaceId: targetParentAnchor, + groupId: targetParentId, + isGroupHeader: true, + frame: CGRect(x: 0, y: 80, width: 180, height: 32) + ), + SidebarWorkspaceReorderDropTarget( + workspaceId: targetAnchor, + groupId: targetGroupId, + isGroupHeader: true, + frame: CGRect(x: 12, y: 120, width: 168, height: 32) + ), + ] + ) + )) + + guard case .reorder(_, _, let explicitGroupId) = plan.action else { + return + } + #expect(explicitGroupId != targetParentId) + } + + private func assertParentGroupPlan( + _ plan: SidebarWorkspaceReorderDropPlan, + parentGroupId: UUID, + targetIndex: Int + ) { + #expect(plan.indicatorScope == .group(parentGroupId)) + guard case .reorder(let resolvedTargetIndex, let usesTopLevelRows, let explicitGroupId) = plan.action else { + Issue.record("Expected parent-group reorder plan") + return + } + #expect(resolvedTargetIndex == targetIndex) + #expect(!usesTopLevelRows) + #expect(explicitGroupId == parentGroupId) + } + + private func nestedFolderRequest( + point: CGPoint, + parentGroupId: UUID, + childGroupId: UUID, + parentAnchor: UUID, + childAnchor: UUID, + childMember: UUID, + dragged: UUID, + outside: UUID, + workspaces: [SidebarWorkspaceReorderWorkspaceSnapshot] + ) -> SidebarWorkspaceReorderDropRequest { + SidebarWorkspaceReorderDropRequest( + point: point, + draggedWorkspaceId: dragged, + workspaces: workspaces, + groups: [ + SidebarWorkspaceReorderGroupSnapshot(id: parentGroupId, anchorWorkspaceId: parentAnchor, isPinned: false), + SidebarWorkspaceReorderGroupSnapshot( + id: childGroupId, + anchorWorkspaceId: childAnchor, + isPinned: false, + parentGroupId: parentGroupId + ), + ], + targets: [ + SidebarWorkspaceReorderDropTarget( + workspaceId: parentAnchor, + groupId: parentGroupId, + isGroupHeader: true, + frame: CGRect(x: 0, y: 0, width: 180, height: 32) + ), + SidebarWorkspaceReorderDropTarget( + workspaceId: childAnchor, + groupId: childGroupId, + isGroupHeader: true, + frame: CGRect(x: 12, y: 40, width: 168, height: 32) + ), + SidebarWorkspaceReorderDropTarget( + workspaceId: childMember, + groupId: childGroupId, + isGroupHeader: false, + frame: CGRect(x: 24, y: 80, width: 156, height: 32) + ), + SidebarWorkspaceReorderDropTarget( + workspaceId: outside, + groupId: nil, + isGroupHeader: false, + frame: CGRect(x: 0, y: 120, width: 180, height: 32) + ), + ] + ) + } } diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Drag/SidebarDragState.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Drag/SidebarDragState.swift index 913ef9623230..cfc52efdfdf9 100644 --- a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Drag/SidebarDragState.swift +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Drag/SidebarDragState.swift @@ -89,7 +89,7 @@ public final class SidebarDragState { scope: SidebarWorkspaceReorderDropIndicatorScope ) { dropIndicator = indicator - dropIndicatorScope = indicator == nil ? .raw : scope + dropIndicatorScope = scope dropIndicatorUsesTopLevelRows = indicator != nil && scope == .topLevel } diff --git a/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarDragStateTests.swift b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarDragStateTests.swift index 3c2a92a1ca20..a907594db9c5 100644 --- a/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarDragStateTests.swift +++ b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarDragStateTests.swift @@ -90,6 +90,21 @@ private final class FakeWorkspaceDragRegistry: SidebarWorkspaceDragRegistering { #expect(state.dropIndicatorUsesTopLevelRows == false) } + @Test func nilDropIndicatorCanKeepGroupScopeForReparentHover() { + let registry = FakeWorkspaceDragRegistry() + let state = SidebarDragState(workspaceDragRegistry: registry) + let groupId = UUID() + + state.setDropIndicator(nil, scope: .group(groupId)) + + #expect(state.dropIndicator == nil) + #expect(state.dropIndicatorScope == .group(groupId)) + #expect(state.dropIndicatorUsesTopLevelRows == false) + + state.clearDropIndicator() + #expect(state.dropIndicatorScope == .raw) + } + @Test func currentWorkspaceDragIdReadsThroughRegistry() { let registry = FakeWorkspaceDragRegistry() let state = SidebarDragState(workspaceDragRegistry: registry) diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift index 70d8d6adb381..06f5f67b9bc5 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift @@ -35,15 +35,28 @@ public final class WorkspaceGroupCoordinator { /// workspace). Its cwd defaults to `anchorWorkingDirectory`, or the first /// eligible child's cwd, or whatever the host's workspace creation /// resolves on its own. + /// + /// - Parameter parentGroupId: Existing folder to contain the new group, or + /// nil to create it at the sidebar root. @discardableResult public func createWorkspaceGroup( name: String, childWorkspaceIds: [UUID] = [], + parentGroupId: UUID? = nil, anchorWorkingDirectory: String? = nil, selectAnchor: Bool = true, collapseSidebarSelection: Bool = true ) -> UUID? { guard let host else { return nil } + let resolvedParentGroupId: UUID? + if let parentGroupId { + guard model.workspaceGroups.contains(where: { $0.id == parentGroupId }) else { + return nil + } + resolvedParentGroupId = parentGroupId + } else { + resolvedParentGroupId = nil + } // Eligible children: not currently an anchor of a different group. // Pulling an anchor into a new group would orphan the // source group (its anchorWorkspaceId would no longer match), so we @@ -78,6 +91,7 @@ public final class WorkspaceGroupCoordinator { name: resolvedName, isCollapsed: false, isPinned: false, + parentGroupId: resolvedParentGroupId, anchorWorkspaceId: anchor.id, customColor: nil, iconSymbol: nil @@ -111,6 +125,12 @@ public final class WorkspaceGroupCoordinator { anchorId: anchor.id ) } + if selectAnchor, + let parentGroupId = resolvedParentGroupId, + let parentIndex = model.workspaceGroups.firstIndex(where: { $0.id == parentGroupId }), + model.workspaceGroups[parentIndex].isCollapsed { + model.workspaceGroups[parentIndex].isCollapsed = false + } host.workspaceOrderDidChange(movedWorkspaceIds: [anchor.id] + eligibleChildren) return group.id } @@ -268,23 +288,25 @@ public final class WorkspaceGroupCoordinator { } /// Dissolve a group while preserving every member workspace (including its - /// anchor) as a regular ungrouped workspace. Nothing is closed. The - /// former members KEEP their `tabs[]` positions so the anchor — which - /// was previously rendered exclusively as the group header — appears as - /// a workspace row at the same vertical spot the header occupied, with - /// the rest of the members staying right below it in their existing - /// relative order. We deliberately do not re-normalize here: that would - /// push the now-ungrouped members down into the "ungrouped tier at the - /// bottom" slot, which makes Ungroup feel like a destructive move - /// instead of a flatten-in-place. + /// anchor). If the group has a parent folder, direct members and child folders + /// are promoted into that parent; otherwise they become top-level rows. public func ungroupWorkspaceGroup(groupId: UUID) { + let parentGroupId = model.workspaceGroups.first(where: { $0.id == groupId })?.parentGroupId + let movedIds = model.workspaceGroupSubtreeWorkspaceIds(groupId: groupId) let memberIds = model.tabs.filter { $0.groupId == groupId }.map(\.id) guard !memberIds.isEmpty || model.workspaceGroups.contains(where: { $0.id == groupId }) else { return } for id in memberIds { - model.assignGroup(workspaceId: id, groupId: nil) + model.assignGroup(workspaceId: id, groupId: parentGroupId) + } + let childGroupIndices = model.workspaceGroups.indices.filter { model.workspaceGroups[$0].parentGroupId == groupId } + for index in childGroupIndices { + model.workspaceGroups[index].parentGroupId = parentGroupId } model.workspaceGroups.removeAll { $0.id == groupId } - host?.workspaceOrderDidChange(movedWorkspaceIds: memberIds) + if parentGroupId != nil || !childGroupIndices.isEmpty { + model.normalizeWorkspaceGroupContiguity() + } + host?.workspaceOrderDidChange(movedWorkspaceIds: movedIds) } /// Delete a group and close every workspace inside it (anchor + all @@ -297,9 +319,15 @@ public final class WorkspaceGroupCoordinator { public func deleteWorkspaceGroup(groupId: UUID, recordHistory: Bool = true) -> Int { guard let host else { return 0 } guard model.workspaceGroups.contains(where: { $0.id == groupId }) else { return 0 } - let members = model.tabs.filter { $0.groupId == groupId } + let deletedGroupIds = model.workspaceGroupSubtreeGroupIds(groupId: groupId) + let members = model.tabs.filter { tab in + guard let tabGroupId = tab.groupId else { return false } + return deletedGroupIds.contains(tabGroupId) + } var closed = 0 + var liveTabIds = Set(model.tabs.map(\.id)) for tab in members { + guard liveTabIds.contains(tab.id) else { continue } // closeWorkspace short-circuits when tabs.count <= 1, so the last // remaining workspace would be left alive with a stale groupId. // Convert the holdout into a regular workspace (clear groupId) @@ -312,13 +340,19 @@ public final class WorkspaceGroupCoordinator { } let countBefore = model.tabs.count host.closeWorkspaceForGroupDeletion(tab, recordHistory: recordHistory) - if model.tabs.count < countBefore { closed += 1 } + if model.tabs.count < countBefore { + closed += 1 + liveTabIds.remove(tab.id) + } } // closeWorkspace's dissolveGroupsAnchoredBy already removes the group // when the anchor is among the closed members, but if every member // was non-anchor (callers can construct that shape via socket // workspace.group.set_anchor races) the group survives — clean up. - model.workspaceGroups.removeAll { $0.id == groupId } + for tab in model.tabs where tab.groupId.map({ deletedGroupIds.contains($0) }) ?? false { + tab.groupId = nil + } + model.workspaceGroups.removeAll { deletedGroupIds.contains($0.id) } return closed } @@ -354,7 +388,7 @@ public final class WorkspaceGroupCoordinator { if let selectedTabId = model.selectedTabId, selectedTabId != anchorId, let selectedTab = model.tabs.first(where: { $0.id == selectedTabId }), - selectedTab.groupId == groupId, + selectedTab.groupId.map({ model.workspaceGroupSubtreeGroupIds(groupId: groupId).contains($0) }) == true, let anchor = model.tabs.first(where: { $0.id == anchorId }) { host.selectWorkspace(anchor) } @@ -363,9 +397,8 @@ public final class WorkspaceGroupCoordinator { // close/group shortcut fired after the collapse would still act // on workspaces the user can no longer see. let hiddenMemberIds: Set = Set( - model.tabs - .filter { $0.groupId == groupId && $0.id != anchorId } - .map(\.id) + model.workspaceGroupSubtreeWorkspaceIds(groupId: groupId) + .filter { $0 != anchorId } ) if !hiddenMemberIds.isEmpty, !host.sidebarSelectedWorkspaceIds.isDisjoint(with: hiddenMemberIds) { @@ -404,7 +437,7 @@ public final class WorkspaceGroupCoordinator { guard model.workspaceGroups[index].isPinned != isPinned else { return } model.workspaceGroups[index].isPinned = isPinned model.normalizeWorkspaceGroupContiguity() - let memberIds = model.tabs.filter { $0.groupId == groupId }.map(\.id) + let memberIds = model.workspaceGroupSubtreeWorkspaceIds(groupId: groupId) host?.workspaceOrderDidChange(movedWorkspaceIds: memberIds) } @@ -443,31 +476,58 @@ public final class WorkspaceGroupCoordinator { // Publish the order change so CmuxEventBus subscribers and any // notification observers see the new anchor position immediately // (other group-mutation paths post; this one was a hole). - let memberIds = model.tabs.filter { $0.groupId == groupId }.map(\.id) + let memberIds = model.workspaceGroupSubtreeWorkspaceIds(groupId: groupId) host?.workspaceOrderDidChange(movedWorkspaceIds: memberIds.isEmpty ? [workspaceId] : memberIds) _ = tab } + /// Reparents one group under another group, or moves it back to the top + /// level when `parentGroupId` is nil. Invalid parents and cycles are ignored. + @discardableResult + public func setWorkspaceGroupParent(groupId: UUID, parentGroupId: UUID?) -> Bool { + guard let index = model.workspaceGroups.firstIndex(where: { $0.id == groupId }) else { return false } + guard model.canSetWorkspaceGroupParent(groupId: groupId, parentGroupId: parentGroupId) else { return false } + guard model.workspaceGroups[index].parentGroupId != parentGroupId else { return true } + model.workspaceGroups[index].parentGroupId = parentGroupId + if let parentGroupId, + let parentIndex = model.workspaceGroups.firstIndex(where: { $0.id == parentGroupId }), + model.workspaceGroups[parentIndex].isCollapsed { + model.workspaceGroups[parentIndex].isCollapsed = false + } + model.normalizeWorkspaceGroupContiguity() + let movedIds = model.workspaceGroupSubtreeWorkspaceIds(groupId: groupId) + host?.workspaceOrderDidChange(movedWorkspaceIds: movedIds) + return true + } + + /// Returns whether a group can be reparented without creating a cycle. + public func canSetWorkspaceGroupParent(groupId: UUID, parentGroupId: UUID?) -> Bool { + model.canSetWorkspaceGroupParent(groupId: groupId, parentGroupId: parentGroupId) + } + // MARK: - Group slots /// Move a group to a new group-slot position. `targetIndex` is interpreted /// as the FINAL position the group should end up at in `workspaceGroups` - /// (post-move). It is clamped to the range occupied by groups in the same - /// pin tier as the source. Ungrouped top-level workspace rows keep their - /// slots; the reordered group anchors are projected back into the existing - /// group slots. + /// (post-move). It is clamped to the range occupied by groups with the same + /// parent and pin tier as the source. Ungrouped top-level workspace rows keep + /// their slots; the reordered group anchors are projected back into the + /// existing group slots. public func moveWorkspaceGroup(groupId: UUID, toIndex targetIndex: Int) { guard moveWorkspaceGroupSlot(groupId: groupId, toIndex: targetIndex) else { return } applyWorkspaceGroupSlotOrderToTabs() - let memberIds = model.tabs.filter { $0.groupId == groupId }.map(\.id) + let memberIds = model.workspaceGroupSubtreeWorkspaceIds(groupId: groupId) host?.workspaceOrderDidChange(movedWorkspaceIds: memberIds) } @discardableResult private func moveWorkspaceGroupSlot(groupId: UUID, toIndex targetIndex: Int) -> Bool { guard let currentIndex = model.workspaceGroups.firstIndex(where: { $0.id == groupId }) else { return false } - let isPinned = model.workspaceGroups[currentIndex].isPinned - let sameTierIndices = model.workspaceGroups.indices.filter { model.workspaceGroups[$0].isPinned == isPinned } + let source = model.workspaceGroups[currentIndex] + let sameTierIndices = model.workspaceGroups.indices.filter { + model.workspaceGroups[$0].parentGroupId == source.parentGroupId && + model.workspaceGroups[$0].isPinned == source.isPinned + } guard let firstSameTier = sameTierIndices.first, let lastSameTier = sameTierIndices.last else { return false } let clampedTarget = max(firstSameTier, min(targetIndex, lastSameTier)) @@ -485,49 +545,43 @@ public final class WorkspaceGroupCoordinator { } private func applyWorkspaceGroupSlotOrderToTabs() { - let groupsByAnchorId = Dictionary(uniqueKeysWithValues: model.workspaceGroups.map { ($0.anchorWorkspaceId, $0) }) - let topLevelIds = model.sidebarTopLevelWorkspaceIds() - let tabsById = Dictionary(uniqueKeysWithValues: model.tabs.map { ($0.id, $0) }) - - var pinnedTopLevelIds: [UUID] = [] - var unpinnedTopLevelIds: [UUID] = [] - pinnedTopLevelIds.reserveCapacity(topLevelIds.count) - unpinnedTopLevelIds.reserveCapacity(topLevelIds.count) - for id in topLevelIds { - let isPinned = groupsByAnchorId[id]?.isPinned ?? (tabsById[id]?.isPinned == true) - if isPinned { - pinnedTopLevelIds.append(id) - } else { - unpinnedTopLevelIds.append(id) - } - } - let tieredTopLevelIds = pinnedTopLevelIds + unpinnedTopLevelIds - - var pinnedAnchors: [UUID] = [] - var unpinnedAnchors: [UUID] = [] - pinnedAnchors.reserveCapacity(model.workspaceGroups.count) - unpinnedAnchors.reserveCapacity(model.workspaceGroups.count) + var groupsByAnchorId: [UUID: WorkspaceGroup] = [:] + var pinnedAnchorsByParentId: [UUID?: [UUID]] = [:] + var unpinnedAnchorsByParentId: [UUID?: [UUID]] = [:] for group in model.workspaceGroups { + if groupsByAnchorId[group.anchorWorkspaceId] == nil { + groupsByAnchorId[group.anchorWorkspaceId] = group + } if group.isPinned { - pinnedAnchors.append(group.anchorWorkspaceId) + pinnedAnchorsByParentId[group.parentGroupId, default: []].append(group.anchorWorkspaceId) } else { - unpinnedAnchors.append(group.anchorWorkspaceId) + unpinnedAnchorsByParentId[group.parentGroupId, default: []].append(group.anchorWorkspaceId) } } - var pinnedAnchorIndex = 0 - var unpinnedAnchorIndex = 0 - let desiredIds = tieredTopLevelIds.map { id -> UUID in - guard let group = groupsByAnchorId[id] else { return id } - if group.isPinned, pinnedAnchorIndex < pinnedAnchors.count { - defer { pinnedAnchorIndex += 1 } - return pinnedAnchors[pinnedAnchorIndex] + + var nextPinnedIndexByParentId: [UUID?: Int] = [:] + var nextUnpinnedIndexByParentId: [UUID?: Int] = [:] + var desiredIds: [UUID] = [] + desiredIds.reserveCapacity(model.tabs.count) + for tab in model.tabs { + guard let group = groupsByAnchorId[tab.id] else { + desiredIds.append(tab.id) + continue } - if !group.isPinned, unpinnedAnchorIndex < unpinnedAnchors.count { - defer { unpinnedAnchorIndex += 1 } - return unpinnedAnchors[unpinnedAnchorIndex] + let parentGroupId = group.parentGroupId + if group.isPinned { + let index = nextPinnedIndexByParentId[parentGroupId, default: 0] + let anchors = pinnedAnchorsByParentId[parentGroupId] ?? [] + desiredIds.append(index < anchors.count ? anchors[index] : tab.id) + nextPinnedIndexByParentId[parentGroupId] = index + 1 + } else { + let index = nextUnpinnedIndexByParentId[parentGroupId, default: 0] + let anchors = unpinnedAnchorsByParentId[parentGroupId] ?? [] + desiredIds.append(index < anchors.count ? anchors[index] : tab.id) + nextUnpinnedIndexByParentId[parentGroupId] = index + 1 } - return id } + model.normalizeWorkspaceGroupRunsPreservingOrder(desiredIds) model.syncWorkspaceGroupsOrderToAnchorOrder() } @@ -551,9 +605,8 @@ public final class WorkspaceGroupCoordinator { /// Place a freshly-created group where its first child already was. /// This keeps "New Group from Selection" visually stable while still - /// making every affected group contiguous and anchor-first. It - /// intentionally preserves top-level order because changing that outer - /// position is the jump this creation path is avoiding. + /// making every affected group contiguous and anchor-first. + /// Preserves top-level order to avoid outer sidebar jumps. private func placeNewWorkspaceGroupAtCreationPosition( groupId: UUID, anchorId: UUID, @@ -564,7 +617,8 @@ public final class WorkspaceGroupCoordinator { let orderedChildIds = originalTabOrder.filter { childIdSet.contains($0) } guard let insertionIndex = originalTabOrder.firstIndex(where: { childIdSet.contains($0) }), !orderedChildIds.isEmpty else { - model.normalizeWorkspaceGroupContiguity() + let originalTopLevelIds = model.topLevelWorkspaceIdsPreservingOrder(originalTabOrder) + model.normalizeWorkspaceGroupContiguity(preservingTopLevelIds: originalTopLevelIds) return } @@ -579,8 +633,12 @@ public final class WorkspaceGroupCoordinator { desiredIds.append(id) } } - model.normalizeWorkspaceGroupContiguity( - preservingTopLevelIds: model.topLevelWorkspaceIdsPreservingOrder(desiredIds) + let preferredTopLevelIds = model.topLevelWorkspaceIdsPreservingOrder(desiredIds) + let pinnedTopLevelIds = model.sidebarTopLevelPinnedWorkspaceIds() + model.normalizeWorkspaceGroupRunsPreservingOrder( + desiredIds, + preferredTopLevelIds: preferredTopLevelIds.filter { pinnedTopLevelIds.contains($0) } + + preferredTopLevelIds.filter { !pinnedTopLevelIds.contains($0) } ) if model.workspaceGroups.contains(where: { $0.id == groupId }) { model.syncWorkspaceGroupsOrderToAnchorOrder() diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceReorderCoordinator.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceReorderCoordinator.swift index e2f95d9565e2..f5cb15851170 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceReorderCoordinator.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceReorderCoordinator.swift @@ -140,7 +140,7 @@ public final class WorkspaceReorderCoordinator { applyDragInferredGroupMembership(workspaceId: tabId, explicitGroupId: explicitGroupId) let currentGroupId = model.tabs.first(where: { $0.id == tabId })?.groupId if currentGroupId != previousGroupId || model.tabs.map(\.id) != previousOrder { - host?.workspaceOrderDidChange(movedWorkspaceIds: [tabId]) + host?.workspaceOrderDidChange(movedWorkspaceIds: movedWorkspaceIdsForReorderedWorkspace(tabId)) } return true } @@ -155,7 +155,7 @@ public final class WorkspaceReorderCoordinator { } model.normalizeWorkspaceGroupContiguity() } - host?.workspaceOrderDidChange(movedWorkspaceIds: [tabId]) + host?.workspaceOrderDidChange(movedWorkspaceIds: movedWorkspaceIdsForReorderedWorkspace(tabId)) return true } @@ -288,7 +288,7 @@ public final class WorkspaceReorderCoordinator { usesTopLevelRows: Bool = false, explicitGroupId: UUID? = nil ) -> Bool { - if usesTopLevelRows || model.isWorkspaceGroupAnchor(tabId) { + if usesTopLevelRows || model.isRootWorkspaceGroupAnchor(tabId) { return reorderTopLevelWorkspaceItem( tabId: tabId, toIndex: targetIndex, @@ -313,44 +313,42 @@ public final class WorkspaceReorderCoordinator { promotingWorkspaceId: promotesGroupedWorkspace ? tabId : nil ) guard let fromIndex = topLevelIds.firstIndex(of: tabId) else { return false } + let promotedGroupIndex = promotesGroupedWorkspace ? model.workspaceGroups.firstIndex { + $0.anchorWorkspaceId == tabId && $0.parentGroupId != nil + } : nil + let promotedTab = promotesGroupedWorkspace ? model.tabs.first { $0.id == tabId } : nil let clampedTarget = model.clampedTopLevelReorderIndex( forWorkspaceId: tabId, targetIndex: targetIndex, topLevelIds: topLevelIds, promotingWorkspaceId: promotesGroupedWorkspace ? tabId : nil ) - let shouldPromoteGroupedWorkspace: Bool = { - guard promotesGroupedWorkspace, - let tab = model.tabs.first(where: { $0.id == tabId }), - tab.groupId != nil, - !model.isWorkspaceGroupAnchor(tabId) else { - return false - } - return true - }() - guard fromIndex != clampedTarget || shouldPromoteGroupedWorkspace else { return false } + let shouldPromoteGroupedWorkspace = promotesGroupedWorkspace && promotedTab?.groupId != nil && !model.isWorkspaceGroupAnchor(tabId) + guard fromIndex != clampedTarget || promotedGroupIndex != nil || shouldPromoteGroupedWorkspace else { return false } var desiredTopLevelIds = topLevelIds if fromIndex != clampedTarget { let movedId = desiredTopLevelIds.remove(at: fromIndex) desiredTopLevelIds.insert(movedId, at: clampedTarget) } - if shouldPromoteGroupedWorkspace { + if let promotedGroupIndex { + model.workspaceGroups[promotedGroupIndex].parentGroupId = nil + } else if shouldPromoteGroupedWorkspace { model.assignGroup(workspaceId: tabId, groupId: nil) } model.normalizeWorkspaceGroupRunsPreservingOrder(desiredTopLevelIds) model.syncWorkspaceGroupsOrderToAnchorOrder() - let movedWorkspaceIds: [UUID] - if let group = model.workspaceGroups.first(where: { $0.anchorWorkspaceId == tabId }) { - movedWorkspaceIds = model.tabs.filter { $0.groupId == group.id }.map(\.id) - } else { - movedWorkspaceIds = [tabId] - } + let movedWorkspaceIds = movedWorkspaceIdsForReorderedWorkspace(tabId) host?.workspaceOrderDidChange(movedWorkspaceIds: movedWorkspaceIds) return true } + private func movedWorkspaceIdsForReorderedWorkspace(_ tabId: UUID) -> [UUID] { + model.workspaceGroups.first { $0.anchorWorkspaceId == tabId } + .map { model.workspaceGroupSubtreeWorkspaceIds(groupId: $0.id) } ?? [tabId] + } + /// Whether a sidebar drag plans in top-level rows (group rows involved /// or a grouped child being promoted out of its group). public func sidebarReorderUsesTopLevelRows( @@ -372,8 +370,8 @@ public final class WorkspaceReorderCoordinator { workspaceGroupIdByWorkspaceId: [UUID: UUID?] ) -> Bool { guard let draggedWorkspaceId else { return false } - if model.isWorkspaceGroupAnchor(draggedWorkspaceId) || - targetWorkspaceId.map(model.isWorkspaceGroupAnchor) == true { + if model.isRootWorkspaceGroupAnchor(draggedWorkspaceId) || + targetWorkspaceId.map(model.isRootWorkspaceGroupAnchor) == true { return true } guard let draggedWorkspaceGroupId = workspaceGroupIdByWorkspaceId[draggedWorkspaceId], diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Model/WorkspaceGroupRunChildRow.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Model/WorkspaceGroupRunChildRow.swift new file mode 100644 index 000000000000..2cde6d5b851e --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Model/WorkspaceGroupRunChildRow.swift @@ -0,0 +1,30 @@ +import Foundation + +/// One renderable child of a workspace-group run: either a nested group (shown +/// at its anchor's position) or a loose member workspace. Built and consumed +/// only inside `@MainActor` `WorkspacesModel` normalization, and it carries a +/// live `Tab` reference whose identity/pin state are `@MainActor`-isolated, so +/// the type is main-actor isolated to match. +@MainActor +enum WorkspaceGroupRunChildRow { + case group(WorkspaceGroup) + case workspace(Tab) + + var workspaceId: UUID { + switch self { + case .group(let group): + return group.anchorWorkspaceId + case .workspace(let tab): + return tab.id + } + } + + var isPinned: Bool { + switch self { + case .group(let group): + return group.isPinned + case .workspace(let tab): + return tab.isPinned + } + } +} diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Model/WorkspacesModel+GroupInvariants.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Model/WorkspacesModel+GroupInvariants.swift index d3312cbffab2..4a947974eee8 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Model/WorkspacesModel+GroupInvariants.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Model/WorkspacesModel+GroupInvariants.swift @@ -13,21 +13,80 @@ extension WorkspacesModel { expandWorkspaceGroupForSelectionIfNeeded() } - /// Rebuild `tabs` by walking a desired top-level workspace order and - /// emitting each workspace group as one contiguous run at its first - /// encountered member. - func normalizeWorkspaceGroupRunsPreservingOrder(_ desiredIds: [UUID]) { - let groupsById = Dictionary(uniqueKeysWithValues: workspaceGroups.map { ($0.id, $0) }) + /// Rebuild `tabs` by walking a desired workspace order and emitting each + /// workspace group as one contiguous subtree at its first encountered row. + /// + /// `desiredIds` can be either a top-level row projection or a full tab order. + /// When it carries a full order, mixed nested children (loose workspaces and + /// subfolders under the same parent) keep their relative positions. + func normalizeWorkspaceGroupRunsPreservingOrder( + _ desiredIds: [UUID], + preferredTopLevelIds: [UUID]? = nil + ) { + var groupsById = Dictionary(uniqueKeysWithValues: workspaceGroups.map { ($0.id, $0) }) let knownGroupIds = Set(groupsById.keys) for tab in tabs where tab.groupId.map({ !knownGroupIds.contains($0) }) ?? false { tab.groupId = nil } - var groupedByGroupId: [UUID: [Tab]] = [:] + for index in workspaceGroups.indices { + if let parentGroupId = workspaceGroups[index].parentGroupId, + (!knownGroupIds.contains(parentGroupId) || + !canSetWorkspaceGroupParent( + groupId: workspaceGroups[index].id, + parentGroupId: parentGroupId, + groupsById: groupsById + )) { + workspaceGroups[index].parentGroupId = nil + groupsById[workspaceGroups[index].id]?.parentGroupId = nil + } + } + let tabsById = Dictionary(uniqueKeysWithValues: tabs.map { ($0.id, $0) }) + let tabIndexById = Dictionary(uniqueKeysWithValues: tabs.enumerated().map { ($0.element.id, $0.offset) }) + var anchorGroupByWorkspaceId: [UUID: WorkspaceGroup] = [:] + for group in groupsById.values where anchorGroupByWorkspaceId[group.anchorWorkspaceId] == nil { + anchorGroupByWorkspaceId[group.anchorWorkspaceId] = group + } + let desiredRankByWorkspaceId = workspaceRankMap(preferredIds: desiredIds) + let topLevelRankByWorkspaceId = preferredTopLevelIds.map { + workspaceRankMap(preferredIds: $0) + } + + var childRowsByParentId: [UUID?: [WorkspaceGroupRunChildRow]] = [:] for tab in tabs { - if let groupId = tab.groupId { - groupedByGroupId[groupId, default: []].append(tab) + if let anchoredGroup = anchorGroupByWorkspaceId[tab.id] { + childRowsByParentId[anchoredGroup.parentGroupId, default: []].append(.group(anchoredGroup)) + } else if let groupId = tab.groupId, + groupsById[groupId] != nil { + childRowsByParentId[Optional(groupId), default: []].append(.workspace(tab)) + } else { + childRowsByParentId[nil, default: []].append(.workspace(tab)) + } + } + + func rowRank(_ row: WorkspaceGroupRunChildRow, parentGroupId: UUID?) -> Int { + if parentGroupId == nil, + let topLevelRankByWorkspaceId, + let rank = topLevelRankByWorkspaceId[row.workspaceId] { + return rank + } + return desiredRankByWorkspaceId[row.workspaceId] + ?? tabIndexById[row.workspaceId] + ?? Int.max + } + + for parentId in Array(childRowsByParentId.keys) { + childRowsByParentId[parentId]?.sort { lhs, rhs in + if lhs.isPinned != rhs.isPinned { + return lhs.isPinned && !rhs.isPinned + } + let lhsRank = rowRank(lhs, parentGroupId: parentId) + let rhsRank = rowRank(rhs, parentGroupId: parentId) + if lhsRank != rhsRank { + return lhsRank < rhsRank + } + return (tabIndexById[lhs.workspaceId] ?? Int.max) < (tabIndexById[rhs.workspaceId] ?? Int.max) } } @@ -36,31 +95,59 @@ extension WorkspacesModel { var reordered: [Tab] = [] reordered.reserveCapacity(tabs.count) - func appendWorkspaceOrGroup(for id: UUID) { - guard let tab = tabsById[id] else { return } - if let groupId = tab.groupId, - let group = groupsById[groupId], - emittedGroupIds.insert(groupId).inserted { - let members = anchorFirst(groupedByGroupId[groupId] ?? [], anchorId: group.anchorWorkspaceId) - for member in members where emittedWorkspaceIds.insert(member.id).inserted { - reordered.append(member) + func appendRows(_ rows: [WorkspaceGroupRunChildRow]) { + var stack: [(rows: [WorkspaceGroupRunChildRow], nextIndex: Int)] = [(rows, 0)] + while var frame = stack.popLast() { + guard frame.nextIndex < frame.rows.count else { continue } + let row = frame.rows[frame.nextIndex] + frame.nextIndex += 1 + stack.append(frame) + + switch row { + case .group(let group): + guard emittedGroupIds.insert(group.id).inserted else { continue } + if let anchor = tabsById[group.anchorWorkspaceId], + emittedWorkspaceIds.insert(anchor.id).inserted { + reordered.append(anchor) + } + if let children = childRowsByParentId[Optional(group.id)], !children.isEmpty { + stack.append((children, 0)) + } + case .workspace(let tab): + if emittedWorkspaceIds.insert(tab.id).inserted { + reordered.append(tab) + } } - } else if tab.groupId == nil, - emittedWorkspaceIds.insert(tab.id).inserted { - reordered.append(tab) } } - for id in desiredIds { - appendWorkspaceOrGroup(for: id) - } + appendRows(childRowsByParentId[nil] ?? []) for tab in tabs where !emittedWorkspaceIds.contains(tab.id) { - appendWorkspaceOrGroup(for: tab.id) + reordered.append(tab) + emittedWorkspaceIds.insert(tab.id) } tabs = reordered } + private func workspaceRankMap(preferredIds: [UUID]) -> [UUID: Int] { + var ranks: [UUID: Int] = [:] + ranks.reserveCapacity(tabs.count) + var nextRank = 0 + func record(_ id: UUID) { + guard ranks[id] == nil else { return } + ranks[id] = nextRank + nextRank += 1 + } + for id in preferredIds { + record(id) + } + for tab in tabs { + record(tab.id) + } + return ranks + } + /// Reorder `tabs` so each group stays contiguous and anchor-first while /// preserving top-level row order inside the pinned and unpinned tiers: /// 1. Pinned top-level rows (pinned workspaces and pinned groups). @@ -72,10 +159,23 @@ extension WorkspacesModel { preservingTopLevelIds preferredTopLevelIds: [UUID]? = nil ) { guard !tabs.isEmpty else { return } - let knownGroupIds = Set(workspaceGroups.map(\.id)) + var groupsById = Dictionary(uniqueKeysWithValues: workspaceGroups.map { ($0.id, $0) }) + let knownGroupIds = Set(groupsById.keys) for tab in tabs where tab.groupId.map({ !knownGroupIds.contains($0) }) ?? false { tab.groupId = nil } + for index in workspaceGroups.indices { + if let parentGroupId = workspaceGroups[index].parentGroupId, + (!knownGroupIds.contains(parentGroupId) || + !canSetWorkspaceGroupParent( + groupId: workspaceGroups[index].id, + parentGroupId: parentGroupId, + groupsById: groupsById + )) { + workspaceGroups[index].parentGroupId = nil + groupsById[workspaceGroups[index].id]?.parentGroupId = nil + } + } let topLevelIds = preferredTopLevelIds ?? sidebarTopLevelWorkspaceIds() let pinnedTopLevelIds = sidebarTopLevelPinnedWorkspaceIds() let desiredIds = topLevelIds.filter { pinnedTopLevelIds.contains($0) } @@ -92,17 +192,26 @@ extension WorkspacesModel { /// didSet hook. No-op when the workspace is ungrouped or its group is already expanded. public func expandWorkspaceGroupForSelectionIfNeeded() { guard let selectedTabId, - let groupId = tabs.first(where: { $0.id == selectedTabId })?.groupId, - let index = workspaceGroups.firstIndex(where: { $0.id == groupId }), - workspaceGroups[index].isCollapsed else { + let groupId = tabs.first(where: { $0.id == selectedTabId })?.groupId else { return } - // The anchor is the group header's visible representation, so - // focusing it doesn't hide it. Skip auto-expand when the focused - // workspace IS the group's anchor — that lets users work in the - // anchor while keeping the rest of the group folded away. - guard workspaceGroups[index].anchorWorkspaceId != selectedTabId else { return } - workspaceGroups[index].isCollapsed = false + var cursor: UUID? = groupId + var visited: Set = [] + let groupIndexById = Dictionary( + uniqueKeysWithValues: workspaceGroups.indices.map { (workspaceGroups[$0].id, $0) } + ) + while let current = cursor, + visited.insert(current).inserted, + let index = groupIndexById[current] { + // The selected group's own anchor is visible as that group's header, + // so selecting it should not force its children open. Ancestors still + // expand so the header itself is reachable in a nested tree. + if workspaceGroups[index].anchorWorkspaceId != selectedTabId, + workspaceGroups[index].isCollapsed { + workspaceGroups[index].isCollapsed = false + } + cursor = workspaceGroups[index].parentGroupId + } } /// Reorder `workspaceGroups` so each group's relative position matches @@ -181,9 +290,21 @@ extension WorkspacesModel { .filter { $0.anchorWorkspaceId == closedWorkspaceId } .map(\.id) guard !dissolvedGroupIds.isEmpty else { return } + let parentByDissolvedGroupId = Dictionary( + uniqueKeysWithValues: workspaceGroups + .filter { dissolvedGroupIds.contains($0.id) } + .map { ($0.id, $0.parentGroupId) } + ) for gid in dissolvedGroupIds { + let promotedGroupId = parentByDissolvedGroupId[gid] ?? nil for tab in tabs where tab.groupId == gid { - tab.groupId = nil + tab.groupId = promotedGroupId + } + } + for index in workspaceGroups.indices { + if let parentGroupId = workspaceGroups[index].parentGroupId, + dissolvedGroupIds.contains(parentGroupId) { + workspaceGroups[index].parentGroupId = parentByDissolvedGroupId[parentGroupId] ?? nil } } workspaceGroups.removeAll { dissolvedGroupIds.contains($0.id) } diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Model/WorkspacesModel+Ordering.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Model/WorkspacesModel+Ordering.swift index 17a07e20cf91..f38b8b75181b 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Model/WorkspacesModel+Ordering.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Model/WorkspacesModel+Ordering.swift @@ -9,6 +9,94 @@ extension WorkspacesModel { workspaceGroups.contains { $0.anchorWorkspaceId == workspaceId } } + /// Whether the workspace anchors a root-level group. + func isRootWorkspaceGroupAnchor(_ workspaceId: UUID) -> Bool { + guard let group = workspaceGroups.first(where: { $0.anchorWorkspaceId == workspaceId }) else { + return false + } + return rootWorkspaceGroup(containing: group.id)?.id == group.id + } + + /// The root folder that contains `groupId`, or nil when the group is unknown. + func rootWorkspaceGroup(containing groupId: UUID) -> WorkspaceGroup? { + let groupsById = Dictionary(uniqueKeysWithValues: workspaceGroups.map { ($0.id, $0) }) + return rootWorkspaceGroup(containing: groupId, groupsById: groupsById) + } + + /// The root folder that contains `groupId`, using a prebuilt group map. + func rootWorkspaceGroup(containing groupId: UUID, groupsById: [UUID: WorkspaceGroup]) -> WorkspaceGroup? { + guard var root = groupsById[groupId] else { return nil } + var visited: Set = [root.id] + while let parentId = root.parentGroupId, + let parent = groupsById[parentId], + visited.insert(parent.id).inserted { + root = parent + } + return root + } + + /// Whether assigning `parentGroupId` as `groupId`'s parent would keep the + /// folder tree valid. + public func canSetWorkspaceGroupParent(groupId: UUID, parentGroupId: UUID?) -> Bool { + let groupsById = Dictionary(uniqueKeysWithValues: workspaceGroups.map { ($0.id, $0) }) + return canSetWorkspaceGroupParent( + groupId: groupId, + parentGroupId: parentGroupId, + groupsById: groupsById + ) + } + + /// Whether assigning `parentGroupId` as `groupId`'s parent would keep the + /// folder tree valid, using a prebuilt group map for hot normalization paths. + func canSetWorkspaceGroupParent( + groupId: UUID, + parentGroupId: UUID?, + groupsById: [UUID: WorkspaceGroup] + ) -> Bool { + guard groupsById[groupId] != nil else { return false } + guard let parentGroupId else { return true } + guard parentGroupId != groupId, + groupsById[parentGroupId] != nil else { + return false + } + var visited: Set = [] + var cursor: UUID? = parentGroupId + while let current = cursor { + guard visited.insert(current).inserted else { return false } + if current == groupId { return false } + cursor = groupsById[current]?.parentGroupId + } + return true + } + + /// Group ids in `groupId`'s subtree, including `groupId`. + public func workspaceGroupSubtreeGroupIds(groupId: UUID) -> Set { + var childrenByParentId: [UUID: [UUID]] = [:] + for group in workspaceGroups { + if let parentGroupId = group.parentGroupId { + childrenByParentId[parentGroupId, default: []].append(group.id) + } + } + + var result: Set = [] + var stack: [UUID] = [groupId] + while let current = stack.popLast() { + guard result.insert(current).inserted else { continue } + stack.append(contentsOf: childrenByParentId[current] ?? []) + } + return result + } + + /// Workspace ids contained by `groupId` and any nested child groups, in tab order. + public func workspaceGroupSubtreeWorkspaceIds(groupId: UUID) -> [UUID] { + let groupIds = workspaceGroupSubtreeGroupIds(groupId: groupId) + return tabs.compactMap { tab in + guard let tabGroupId = tab.groupId, + groupIds.contains(tabGroupId) else { return nil } + return tab.id + } + } + /// The top-level sidebar row id for each given workspace (its group's /// anchor when grouped, itself when ungrouped), deduplicated in order. func topLevelWorkspaceIds(for workspaces: [Tab]) -> [UUID] { @@ -19,8 +107,8 @@ extension WorkspacesModel { for workspace in workspaces { let topLevelId: UUID if let groupId = workspace.groupId, - let group = groupsById[groupId] { - topLevelId = group.anchorWorkspaceId + let root = rootWorkspaceGroup(containing: groupId, groupsById: groupsById) { + topLevelId = root.anchorWorkspaceId } else { topLevelId = workspace.id } @@ -38,14 +126,14 @@ extension WorkspacesModel { let groupsById = Dictionary(uniqueKeysWithValues: workspaceGroups.map { ($0.id, $0) }) let groupsByAnchorId = Dictionary(uniqueKeysWithValues: workspaceGroups.map { ($0.anchorWorkspaceId, $0) }) let tabsById = Dictionary(uniqueKeysWithValues: tabs.map { ($0.id, $0) }) - var emittedGroupIds = Set() + var emittedRootGroupIds = Set() var ids: [UUID] = [] ids.reserveCapacity(tabs.count) for tab in tabs { if let groupId = tab.groupId, - let group = groupsById[groupId] { - if emittedGroupIds.insert(groupId).inserted { - ids.append(group.anchorWorkspaceId) + let rootGroup = rootWorkspaceGroup(containing: groupId, groupsById: groupsById) { + if emittedRootGroupIds.insert(rootGroup.id).inserted { + ids.append(rootGroup.anchorWorkspaceId) } } else { ids.append(tab.id) @@ -55,8 +143,8 @@ extension WorkspacesModel { !ids.contains(promotedWorkspaceId), let tab = tabsById[promotedWorkspaceId], let groupId = tab.groupId, - let group = groupsById[groupId], - let groupIndex = ids.firstIndex(of: group.anchorWorkspaceId) { + let rootGroup = rootWorkspaceGroup(containing: groupId, groupsById: groupsById), + let groupIndex = ids.firstIndex(of: rootGroup.anchorWorkspaceId) { ids.insert( promotedWorkspaceId, at: promotedTopLevelInsertionIndex( @@ -77,7 +165,7 @@ extension WorkspacesModel { let groupsById = Dictionary(uniqueKeysWithValues: workspaceGroups.map { ($0.id, $0) }) let tabsById = Dictionary(uniqueKeysWithValues: tabs.map { ($0.id, $0) }) var emittedWorkspaceIds = Set() - var emittedGroupIds = Set() + var emittedRootGroupIds = Set() var ids: [UUID] = [] ids.reserveCapacity(tabs.count) @@ -85,9 +173,9 @@ extension WorkspacesModel { guard let tab = tabsById[id], emittedWorkspaceIds.insert(tab.id).inserted else { return } if let groupId = tab.groupId, - let group = groupsById[groupId] { - if emittedGroupIds.insert(groupId).inserted { - ids.append(group.anchorWorkspaceId) + let rootGroup = rootWorkspaceGroup(containing: groupId, groupsById: groupsById) { + if emittedRootGroupIds.insert(rootGroup.id).inserted { + ids.append(rootGroup.anchorWorkspaceId) } } else { ids.append(tab.id) @@ -224,19 +312,25 @@ extension WorkspacesModel { /// The number of leading rows in `tabs[]` that render as pinned. func leadingGlobalPinnedRowCount() -> Int { + let groupsById = Dictionary(uniqueKeysWithValues: workspaceGroups.map { ($0.id, $0) }) var count = 0 for tab in tabs { - guard isGlobalPinnedRow(tab) else { break } + guard isGlobalPinnedRow(tab, groupsById: groupsById) else { break } count += 1 } return count } - /// Whether the row renders as pinned: group pin for grouped members, + /// Whether the row renders as pinned: root group pin for grouped members, /// workspace pin otherwise. func isGlobalPinnedRow(_ tab: Tab) -> Bool { + let groupsById = Dictionary(uniqueKeysWithValues: workspaceGroups.map { ($0.id, $0) }) + return isGlobalPinnedRow(tab, groupsById: groupsById) + } + + private func isGlobalPinnedRow(_ tab: Tab, groupsById: [UUID: WorkspaceGroup]) -> Bool { if let groupId = tab.groupId, - let group = workspaceGroups.first(where: { $0.id == groupId }) { + let group = rootWorkspaceGroup(containing: groupId, groupsById: groupsById) { return group.isPinned } return tab.isPinned diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Values/WorkspaceGroup.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Values/WorkspaceGroup.swift index c46ae8deb218..a140e5218781 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Values/WorkspaceGroup.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Values/WorkspaceGroup.swift @@ -2,8 +2,9 @@ public import Foundation /// Named collapsible sidebar group containing one or more workspaces. /// The membership relation lives on `Workspace.groupId`; this struct stores -/// the group's identity, display name, collapse/pin state, and the explicit -/// anchor workspace whose lifecycle gates the group itself. +/// the group's identity, display name, collapse/pin state, optional parent +/// folder, and the explicit anchor workspace whose lifecycle gates the group +/// itself. /// /// The anchor workspace is always a real member workspace. It is created /// fresh when the group is created (never promoted from an existing member), @@ -18,6 +19,9 @@ public struct WorkspaceGroup: Identifiable, Equatable, Sendable { public var isCollapsed: Bool /// Whether the group is pinned. public var isPinned: Bool + /// Parent group containing this group as a nested folder, or nil for a + /// top-level sidebar folder. + public var parentGroupId: UUID? /// Identifier of the member workspace that owns this group's lifecycle. /// Always present and always points to a workspace in the window's tabs /// whose `groupId == self.id`. Closing this workspace dissolves the group. @@ -29,12 +33,22 @@ public struct WorkspaceGroup: Identifiable, Equatable, Sendable { /// SF symbol name for the header icon. When nil, defaults to `folder.fill`. public var iconSymbol: String? - /// Creates a group (memberwise; mirrors the legacy app-side shape). + /// Creates a workspace group value. + /// + /// - Parameter id: Stable group identity. + /// - Parameter name: Display name shown in the sidebar. + /// - Parameter isCollapsed: Whether child rows are hidden in the sidebar. + /// - Parameter isPinned: Whether this folder participates in its pin tier. + /// - Parameter parentGroupId: Containing folder, or nil for a root folder. + /// - Parameter anchorWorkspaceId: Member workspace that renders the header. + /// - Parameter customColor: Optional group-level color override. + /// - Parameter iconSymbol: Optional SF Symbol name for the header icon. public init( id: UUID, name: String, isCollapsed: Bool, isPinned: Bool, + parentGroupId: UUID? = nil, anchorWorkspaceId: UUID, customColor: String?, iconSymbol: String? @@ -43,6 +57,7 @@ public struct WorkspaceGroup: Identifiable, Equatable, Sendable { self.name = name self.isCollapsed = isCollapsed self.isPinned = isPinned + self.parentGroupId = parentGroupId self.anchorWorkspaceId = anchorWorkspaceId self.customColor = customColor self.iconSymbol = iconSymbol diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/UngroupPromotionTab.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/UngroupPromotionTab.swift new file mode 100644 index 000000000000..e8a4bce48142 --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/UngroupPromotionTab.swift @@ -0,0 +1,15 @@ +import Foundation +@testable import CmuxWorkspaces + +@MainActor +final class UngroupPromotionTab: WorkspaceTabRepresenting { + let id = UUID() + var groupId: UUID? + var isPinned: Bool + let currentDirectory = "/tmp" + + init(groupId: UUID?, isPinned: Bool = false) { + self.groupId = groupId + self.isPinned = isPinned + } +} diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceCoordinatorTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceCoordinatorTests.swift index 06757a6d7d1d..9bb47748ff9f 100644 --- a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceCoordinatorTests.swift +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceCoordinatorTests.swift @@ -4,7 +4,7 @@ import CmuxSettings @testable import CmuxWorkspaces @MainActor -private final class CoordinatorStubTab: WorkspaceTabRepresenting { +final class CoordinatorStubTab: WorkspaceTabRepresenting { let id: UUID var groupId: UUID? var isPinned: Bool @@ -26,7 +26,7 @@ private final class CoordinatorStubTab: WorkspaceTabRepresenting { /// inverted effect, and removes closed tabs from the model like the real /// `closeWorkspace` teardown does. @MainActor -private final class StubGroupHost: WorkspaceGroupHosting { +final class StubGroupHost: WorkspaceGroupHosting { typealias Tab = CoordinatorStubTab let model: WorkspacesModel @@ -105,7 +105,7 @@ private final class StubGroupHost: WorkspaceGroupHosting { @MainActor struct WorkspaceCoordinatorTests { - private func makeWorld() -> ( + func makeWorld() -> ( model: WorkspacesModel, host: StubGroupHost, groups: WorkspaceGroupCoordinator, @@ -484,6 +484,40 @@ struct WorkspaceCoordinatorTests { #expect(model.tabs.first(where: { $0.id == firstAnchor })?.groupId == firstGroupId) } + @Test + func createNestedWorkspaceGroupPreservesParentChildInsertionPoint() throws { + let (model, host, groups, _) = makeWorld() + _ = host + let a = CoordinatorStubTab() + let b = CoordinatorStubTab() + let c = CoordinatorStubTab() + let outside = CoordinatorStubTab() + model.tabs = [a, b, c, outside] + let parentId = try #require(groups.createWorkspaceGroup(name: "Hotels", childWorkspaceIds: [ + a.id, + b.id, + c.id, + ])) + let parentAnchorId = try #require(model.workspaceGroups.first { $0.id == parentId }?.anchorWorkspaceId) + + let childId = try #require(groups.createWorkspaceGroup( + name: "Marriott", + childWorkspaceIds: [b.id], + parentGroupId: parentId + )) + let childAnchorId = try #require(model.workspaceGroups.first { $0.id == childId }?.anchorWorkspaceId) + + #expect(model.tabs.map(\.id) == [ + parentAnchorId, + a.id, + childAnchorId, + b.id, + c.id, + outside.id, + ]) + #expect(model.workspaceGroups.first { $0.id == childId }?.parentGroupId == parentId) + } + @Test func deleteWorkspaceGroupClosesMembersAndClearsLastHoldout() throws { let (model, host, groups, _) = makeWorld() @@ -571,4 +605,280 @@ struct WorkspaceCoordinatorTests { let memberIds = model.tabs.filter { $0.groupId == groupId }.map(\.id) #expect(memberIds.first == b.id) } + + @Test + func reparentWorkspaceGroupRejectsCycles() throws { + let (model, host, groups, _) = makeWorld() + _ = host + let hotelsMember = CoordinatorStubTab() + let marriottMember = CoordinatorStubTab() + model.tabs = [hotelsMember, marriottMember] + let hotelsId = try #require(groups.createWorkspaceGroup(name: "Hotels", childWorkspaceIds: [hotelsMember.id])) + let marriottId = try #require(groups.createWorkspaceGroup( + name: "Marriott", + childWorkspaceIds: [marriottMember.id], + parentGroupId: hotelsId + )) + + #expect(model.workspaceGroups.first { $0.id == marriottId }?.parentGroupId == hotelsId) + #expect(!groups.canSetWorkspaceGroupParent(groupId: hotelsId, parentGroupId: marriottId)) + #expect(!groups.setWorkspaceGroupParent(groupId: hotelsId, parentGroupId: marriottId)) + #expect(model.workspaceGroups.first { $0.id == hotelsId }?.parentGroupId == nil) + + #expect(groups.setWorkspaceGroupParent(groupId: marriottId, parentGroupId: nil)) + #expect(model.workspaceGroups.first { $0.id == marriottId }?.parentGroupId == nil) + } + + @Test + func moveNestedWorkspaceGroupReordersOnlySiblings() throws { + let (model, host, groups, _) = makeWorld() + _ = host + let hotelsMember = CoordinatorStubTab() + let marriottMember = CoordinatorStubTab() + let hiltonMember = CoordinatorStubTab() + model.tabs = [hotelsMember, marriottMember, hiltonMember] + let hotelsId = try #require(groups.createWorkspaceGroup(name: "Hotels", childWorkspaceIds: [hotelsMember.id])) + let marriottId = try #require(groups.createWorkspaceGroup( + name: "Marriott", + childWorkspaceIds: [marriottMember.id], + parentGroupId: hotelsId + )) + let hiltonId = try #require(groups.createWorkspaceGroup( + name: "Hilton", + childWorkspaceIds: [hiltonMember.id], + parentGroupId: hotelsId + )) + let marriottIndex = try #require(model.workspaceGroups.firstIndex { $0.id == marriottId }) + + groups.moveWorkspaceGroup(groupId: hiltonId, toIndex: marriottIndex) + + let childOrder = model.workspaceGroups + .filter { $0.parentGroupId == hotelsId } + .map(\.id) + #expect(childOrder == [hiltonId, marriottId]) + #expect(model.workspaceGroups.first?.id == hotelsId) + #expect(model.tabs.first?.id == model.workspaceGroups.first { $0.id == hotelsId }?.anchorWorkspaceId) + let hiltonAnchorId = try #require(model.workspaceGroups.first { $0.id == hiltonId }?.anchorWorkspaceId) + let marriottAnchorId = try #require(model.workspaceGroups.first { $0.id == marriottId }?.anchorWorkspaceId) + let tabIds = model.tabs.map(\.id) + let hiltonAnchorIndex = try #require(tabIds.firstIndex(of: hiltonAnchorId)) + let hiltonMemberIndex = try #require(tabIds.firstIndex(of: hiltonMember.id)) + let marriottAnchorIndex = try #require(tabIds.firstIndex(of: marriottAnchorId)) + let marriottMemberIndex = try #require(tabIds.firstIndex(of: marriottMember.id)) + #expect(hiltonAnchorIndex < hiltonMemberIndex) + #expect(hiltonMemberIndex < marriottAnchorIndex) + #expect(marriottAnchorIndex < marriottMemberIndex) + } + + @Test + func topLevelDragPromotesNestedWorkspaceGroupToRoot() throws { + let (model, host, groups, reorder) = makeWorld() + _ = host + let hotelsMember = CoordinatorStubTab() + let marriottMember = CoordinatorStubTab() + let outside = CoordinatorStubTab() + model.tabs = [hotelsMember, marriottMember, outside] + let hotelsId = try #require(groups.createWorkspaceGroup(name: "Hotels", childWorkspaceIds: [hotelsMember.id])) + let marriottId = try #require(groups.createWorkspaceGroup( + name: "Marriott", + childWorkspaceIds: [marriottMember.id], + parentGroupId: hotelsId + )) + let marriottAnchorId = try #require(model.workspaceGroups.first { $0.id == marriottId }?.anchorWorkspaceId) + + let moved = reorder.reorderSidebarWorkspace( + tabId: marriottAnchorId, + toIndex: 0, + isDragOperation: true, + usesTopLevelRows: true + ) + + #expect(moved) + #expect(model.workspaceGroups.first { $0.id == marriottId }?.parentGroupId == nil) + #expect(Array(model.workspaceGroups.map(\.id).prefix(2)) == [marriottId, hotelsId]) + } + + @Test + func topLevelDragPromotesPinnedNestedWorkspaceGroupToRootPinnedTier() throws { + let (model, host, groups, reorder) = makeWorld() + _ = host + let hotelsMember = CoordinatorStubTab() + let marriottMember = CoordinatorStubTab() + let outside = CoordinatorStubTab() + model.tabs = [hotelsMember, marriottMember, outside] + let hotelsId = try #require(groups.createWorkspaceGroup(name: "Hotels", childWorkspaceIds: [hotelsMember.id])) + let marriottId = try #require(groups.createWorkspaceGroup( + name: "Marriott", + childWorkspaceIds: [marriottMember.id], + parentGroupId: hotelsId + )) + groups.setWorkspaceGroupPinned(groupId: marriottId, isPinned: true) + let marriottAnchorId = try #require(model.workspaceGroups.first { $0.id == marriottId }?.anchorWorkspaceId) + + let moved = reorder.reorderSidebarWorkspace( + tabId: marriottAnchorId, + toIndex: 2, + isDragOperation: true, + usesTopLevelRows: true + ) + + #expect(moved) + #expect(model.workspaceGroups.first { $0.id == marriottId }?.parentGroupId == nil) + #expect(model.workspaceGroups.first?.id == marriottId) + #expect(model.tabs.first?.id == marriottAnchorId) + } + + @Test + func topLevelDragClassifiesRootGroupByGroupPinStateNotAnchorPinState() throws { + let (model, host, groups, reorder) = makeWorld() + _ = host + let pinnedSolo = CoordinatorStubTab(isPinned: true) + let groupMember = CoordinatorStubTab() + let outside = CoordinatorStubTab() + model.tabs = [pinnedSolo, groupMember, outside] + let groupId = try #require(groups.createWorkspaceGroup(name: "Hotels", childWorkspaceIds: [groupMember.id])) + let group = try #require(model.workspaceGroups.first { $0.id == groupId }) + let anchor = try #require(model.tabs.first { $0.id == group.anchorWorkspaceId }) + anchor.isPinned = true + let orderBefore = model.tabs.map(\.id) + + let moved = reorder.reorderSidebarWorkspace( + tabId: group.anchorWorkspaceId, + toIndex: 0, + isDragOperation: true, + usesTopLevelRows: true + ) + + #expect(!group.isPinned) + #expect(!moved) + #expect(model.tabs.map(\.id) == orderBefore) + } + + @Test + func reorderingRootGroupPublishesDescendantWorkspaceIds() throws { + let (model, host, groups, reorder) = makeWorld() + let hotelsMember = CoordinatorStubTab() + let marriottMember = CoordinatorStubTab() + let outside = CoordinatorStubTab() + model.tabs = [hotelsMember, marriottMember, outside] + let hotelsId = try #require(groups.createWorkspaceGroup(name: "Hotels", childWorkspaceIds: [hotelsMember.id])) + let marriottId = try #require(groups.createWorkspaceGroup( + name: "Marriott", + childWorkspaceIds: [marriottMember.id], + parentGroupId: hotelsId + )) + let hotelsAnchorId = try #require(model.workspaceGroups.first { $0.id == hotelsId }?.anchorWorkspaceId) + let marriottAnchorId = try #require(model.workspaceGroups.first { $0.id == marriottId }?.anchorWorkspaceId) + + #expect(reorder.reorderSidebarWorkspace(tabId: hotelsAnchorId, toIndex: 1)) + + #expect(Set(host.orderChanges.last ?? []) == Set([ + hotelsAnchorId, + hotelsMember.id, + marriottAnchorId, + marriottMember.id, + ])) + } + + @Test + func closingNestedGroupAnchorPromotesMembersToParent() throws { + let (model, host, groups, _) = makeWorld() + _ = host + let hotelsMember = CoordinatorStubTab() + let marriottMember = CoordinatorStubTab() + model.tabs = [hotelsMember, marriottMember] + let hotelsId = try #require(groups.createWorkspaceGroup(name: "Hotels", childWorkspaceIds: [hotelsMember.id])) + let marriottId = try #require(groups.createWorkspaceGroup( + name: "Marriott", + childWorkspaceIds: [marriottMember.id], + parentGroupId: hotelsId + )) + let marriottAnchorId = try #require(model.workspaceGroups.first { $0.id == marriottId }?.anchorWorkspaceId) + let anchorIndex = try #require(model.tabs.firstIndex { $0.id == marriottAnchorId }) + model.tabs.remove(at: anchorIndex) + + model.dissolveGroupsAnchoredBy(closedWorkspaceId: marriottAnchorId) + + #expect(model.workspaceGroups.allSatisfy { $0.id != marriottId }) + #expect(model.tabs.first { $0.id == marriottMember.id }?.groupId == hotelsId) + } + + @Test + func pinnedNestedWorkspaceGroupNormalizesBeforeUnpinnedSiblings() throws { + let (model, host, groups, _) = makeWorld() + _ = host + let hotelsMember = CoordinatorStubTab() + let marriottMember = CoordinatorStubTab() + let hiltonMember = CoordinatorStubTab() + model.tabs = [hotelsMember, marriottMember, hiltonMember] + let hotelsId = try #require(groups.createWorkspaceGroup(name: "Hotels", childWorkspaceIds: [hotelsMember.id])) + let marriottId = try #require(groups.createWorkspaceGroup( + name: "Marriott", + childWorkspaceIds: [marriottMember.id], + parentGroupId: hotelsId + )) + let hiltonId = try #require(groups.createWorkspaceGroup( + name: "Hilton", + childWorkspaceIds: [hiltonMember.id], + parentGroupId: hotelsId + )) + + groups.setWorkspaceGroupPinned(groupId: hiltonId, isPinned: true) + + let childOrder = model.workspaceGroups + .filter { $0.parentGroupId == hotelsId } + .map(\.id) + #expect(childOrder == [hiltonId, marriottId]) + let hiltonAnchorId = try #require(model.workspaceGroups.first { $0.id == hiltonId }?.anchorWorkspaceId) + let marriottAnchorId = try #require(model.workspaceGroups.first { $0.id == marriottId }?.anchorWorkspaceId) + let tabIds = model.tabs.map(\.id) + let hiltonAnchorIndex = try #require(tabIds.firstIndex(of: hiltonAnchorId)) + let marriottAnchorIndex = try #require(tabIds.firstIndex(of: marriottAnchorId)) + #expect(hiltonAnchorIndex < marriottAnchorIndex) + } + + @Test + func collapsingParentGroupMovesFocusFromDescendantToParentAnchor() throws { + let (model, host, groups, _) = makeWorld() + let hotelsMember = CoordinatorStubTab() + let marriottMember = CoordinatorStubTab() + model.tabs = [hotelsMember, marriottMember] + let hotelsId = try #require(groups.createWorkspaceGroup(name: "Hotels", childWorkspaceIds: [hotelsMember.id])) + let hotelsAnchorId = try #require(model.workspaceGroups.first { $0.id == hotelsId }?.anchorWorkspaceId) + _ = groups.createWorkspaceGroup( + name: "Marriott", + childWorkspaceIds: [marriottMember.id], + parentGroupId: hotelsId + ) + model.selectedTabId = marriottMember.id + host.sidebarSelectedWorkspaceIds = [marriottMember.id] + + groups.toggleWorkspaceGroupCollapsed(groupId: hotelsId) + + #expect(model.workspaceGroups.first { $0.id == hotelsId }?.isCollapsed == true) + #expect(host.selectedWorkspaceIds == [hotelsAnchorId]) + #expect(host.subtractedSidebarSelections.last?.hidden.contains(marriottMember.id) == true) + } + + @Test + func deletingParentGroupDeletesDescendantGroupsAndMembers() throws { + let (model, host, groups, _) = makeWorld() + let hotelsMember = CoordinatorStubTab() + let marriottMember = CoordinatorStubTab() + let outside = CoordinatorStubTab() + model.tabs = [hotelsMember, marriottMember, outside] + let hotelsId = try #require(groups.createWorkspaceGroup(name: "Hotels", childWorkspaceIds: [hotelsMember.id])) + let marriottId = try #require(groups.createWorkspaceGroup( + name: "Marriott", + childWorkspaceIds: [marriottMember.id], + parentGroupId: hotelsId + )) + + let closed = groups.deleteWorkspaceGroup(groupId: hotelsId) + + #expect(closed == 4) + #expect(model.workspaceGroups.allSatisfy { $0.id != hotelsId && $0.id != marriottId }) + #expect(model.tabs.map(\.id) == [outside.id]) + #expect(host.closedWorkspaceIds.count == 4) + } } diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceGroupCreationPlacementRegressionTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceGroupCreationPlacementRegressionTests.swift new file mode 100644 index 000000000000..0c1094f79f88 --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceGroupCreationPlacementRegressionTests.swift @@ -0,0 +1,32 @@ +import Testing + +@testable import CmuxWorkspaces + +@MainActor +struct WorkspaceGroupCreationPlacementRegressionTests { + @Test + func emptyNestedWorkspaceGroupPreservesParentTopLevelPosition() throws { + let (model, host, groups, _) = WorkspaceCoordinatorTests().makeWorld() + _ = host + let before = CoordinatorStubTab() + let parentMember = CoordinatorStubTab() + let after = CoordinatorStubTab() + model.tabs = [before, parentMember, after] + let parentId = try #require(groups.createWorkspaceGroup( + name: "Parent", + childWorkspaceIds: [parentMember.id], + selectAnchor: false + )) + let parentAnchorId = try #require(model.workspaceGroups.first { $0.id == parentId }?.anchorWorkspaceId) + #expect(model.sidebarTopLevelWorkspaceIds() == [before.id, parentAnchorId, after.id]) + + let childId = try #require(groups.createWorkspaceGroup( + name: "Child", + parentGroupId: parentId, + selectAnchor: false + )) + + #expect(model.workspaceGroups.first { $0.id == childId }?.parentGroupId == parentId) + #expect(model.sidebarTopLevelWorkspaceIds() == [before.id, parentAnchorId, after.id]) + } +} diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceGroupUngroupPromotionTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceGroupUngroupPromotionTests.swift new file mode 100644 index 000000000000..dce5521a46d0 --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceGroupUngroupPromotionTests.swift @@ -0,0 +1,51 @@ +import Foundation +import Testing +@testable import CmuxWorkspaces + +@MainActor +@Suite struct WorkspaceGroupUngroupPromotionTests { + @Test func ungroupRootNormalizesPinnedChildFolderPromotedToRoot() throws { + let rootId = UUID() + let childId = UUID() + let rootAnchor = UngroupPromotionTab(groupId: rootId) + let rootMember = UngroupPromotionTab(groupId: rootId) + let childAnchor = UngroupPromotionTab(groupId: childId) + let childMember = UngroupPromotionTab(groupId: childId) + let model = WorkspacesModel() + model.tabs = [ + rootAnchor, + rootMember, + childAnchor, + childMember, + ] + model.workspaceGroups = [ + WorkspaceGroup( + id: rootId, + name: "Root", + isCollapsed: false, + isPinned: false, + anchorWorkspaceId: rootAnchor.id, + customColor: nil, + iconSymbol: nil + ), + WorkspaceGroup( + id: childId, + name: "Child", + isCollapsed: false, + isPinned: true, + parentGroupId: rootId, + anchorWorkspaceId: childAnchor.id, + customColor: nil, + iconSymbol: nil + ), + ] + let groups = WorkspaceGroupCoordinator(model: model) + + groups.ungroupWorkspaceGroup(groupId: rootId) + + let childGroup = try #require(model.workspaceGroups.first { $0.id == childId }) + #expect(childGroup.parentGroupId == nil) + #expect(model.tabs.first?.id == childAnchor.id) + #expect(model.sidebarTopLevelWorkspaceIds().first == childAnchor.id) + } +} diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceNestedGroupReorderPublishingTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceNestedGroupReorderPublishingTests.swift new file mode 100644 index 000000000000..6cb70516fd96 --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceNestedGroupReorderPublishingTests.swift @@ -0,0 +1,28 @@ +import Foundation +import Testing +@testable import CmuxWorkspaces + +@MainActor +@Suite struct WorkspaceNestedGroupReorderPublishingTests { + @Test func reorderingNestedGroupPublishesDescendantWorkspaceIds() throws { + let (model, host, groups, reorder) = WorkspaceCoordinatorTests().makeWorld() + let hotelsMember = CoordinatorStubTab() + let marriottMember = CoordinatorStubTab() + let outside = CoordinatorStubTab() + model.tabs = [hotelsMember, marriottMember, outside] + let hotelsId = try #require(groups.createWorkspaceGroup(name: "Hotels", childWorkspaceIds: [hotelsMember.id])) + let marriottId = try #require(groups.createWorkspaceGroup( + name: "Marriott", + childWorkspaceIds: [marriottMember.id], + parentGroupId: hotelsId + )) + let marriottAnchorId = try #require(model.workspaceGroups.first { $0.id == marriottId }?.anchorWorkspaceId) + + #expect(reorder.reorderSidebarWorkspace(tabId: marriottAnchorId, toIndex: 1)) + + #expect(Set(host.orderChanges.last ?? []) == Set([ + marriottAnchorId, + marriottMember.id, + ])) + } +} diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceNestedPinnedOrderingTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceNestedPinnedOrderingTests.swift new file mode 100644 index 000000000000..b37651800853 --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceNestedPinnedOrderingTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing + +@testable import CmuxWorkspaces + +@MainActor +@Suite struct WorkspaceNestedPinnedOrderingTests { + @Test func pinningUngroupedWorkspaceDoesNotSplitPinnedRootFolderSubtree() throws { + let model = WorkspacesModel() + let host = StubGroupHost(model: model) + let groups = WorkspaceGroupCoordinator(model: model) + groups.attach(host: host) + let reorder = WorkspaceReorderCoordinator(model: model) + reorder.attach(host: host) + + let hotelsMember = CoordinatorStubTab() + let marriottMember = CoordinatorStubTab() + let outside = CoordinatorStubTab() + model.tabs = [hotelsMember, marriottMember, outside] + let hotelsId = try #require(groups.createWorkspaceGroup(name: "Hotels", childWorkspaceIds: [hotelsMember.id])) + let marriottId = try #require(groups.createWorkspaceGroup( + name: "Marriott", + childWorkspaceIds: [marriottMember.id], + parentGroupId: hotelsId + )) + groups.setWorkspaceGroupPinned(groupId: hotelsId, isPinned: true) + let hotelsAnchorId = try #require(model.workspaceGroups.first { $0.id == hotelsId }?.anchorWorkspaceId) + let marriottAnchorId = try #require(model.workspaceGroups.first { $0.id == marriottId }?.anchorWorkspaceId) + + reorder.setPinned(outside, pinned: true) + + #expect(model.workspaceGroups.first { $0.id == hotelsId }?.isPinned == true) + #expect(model.workspaceGroups.first { $0.id == marriottId }?.isPinned == false) + #expect(model.tabs.map(\.id) == [ + hotelsAnchorId, + hotelsMember.id, + marriottAnchorId, + marriottMember.id, + outside.id, + ]) + } +} diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 4028a5ebc9f9..81cdc7606174 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -215596,6 +215596,131 @@ } } }, + "workspaceGroup.contextMenu.newSubfolder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Folder" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ថតថ្មី" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規フォルダ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 폴더" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新建文件夹" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增資料夾" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neuer Ordner" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nueva carpeta" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouveau dossier" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nuova cartella" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ny mappe" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ny mappe" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nowy folder" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Новая папка" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โฟลเดอร์ใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeni Klasör" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Нова папка" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مجلد جديد" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nova fascikla" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nova Pasta" + } + } + } + }, "workspaceGroup.contextMenu.openDocs": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 3c060841eac9..ecde9933b5c0 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -14757,7 +14757,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } // No name prompt: TabManager auto-names ("Group N"). Rename via the // header context menu. - tabManager.createWorkspaceGroup(name: "", childWorkspaceIds: eligibleIds) + tabManager.createWorkspaceGroup( + name: "", + childWorkspaceIds: eligibleIds, + parentGroupId: tabManager.commonWorkspaceGroupId(for: eligibleIds) + ) return true } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 5378a63140e3..1fd99a1b558f 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -10211,21 +10211,17 @@ struct VerticalTabsSidebar: View { scope: SidebarWorkspaceReorderDropIndicatorScope, tabs: [Workspace], workspaceGroups: [WorkspaceGroup], - visibleWorkspaceRowIds: [UUID] + workspaceRenderItems: [SidebarWorkspaceRenderItem] ) -> [UUID] { - switch scope { - case .raw: - return tabs.map(\.id) - case .topLevel: - return tabManager.sidebarReorderWorkspaceIds( + SidebarDropIndicatorRowScopeResolver( + tabs: tabs, + workspaceGroups: workspaceGroups, + workspaceRenderItems: workspaceRenderItems, + topLevelWorkspaceRowIds: tabManager.sidebarReorderWorkspaceIds( forDraggedWorkspaceId: draggedWorkspaceId, usesTopLevelRows: true ) - case .group(let groupId): - guard workspaceGroups.contains(where: { $0.id == groupId }) else { return [] } - let visibleIds = Set(visibleWorkspaceRowIds) - return tabs.filter { $0.groupId == groupId && visibleIds.contains($0.id) }.map(\.id) - } + ).rowIds(for: scope) } private var sidebarTopScrimHeight: CGFloat { @@ -10325,7 +10321,8 @@ struct VerticalTabsSidebar: View { .flatMap { renderContext.workspaceGroupById[$0] } proxy.scrollTo(SidebarSelectedWorkspaceScrollPolicy.scrollTargetWorkspaceId( selectedWorkspaceId: selectedWorkspaceId, - group: group + group: group, + groupsById: renderContext.workspaceGroupById )) pendingSelectedWorkspaceScrollId = nil } @@ -10374,12 +10371,93 @@ struct VerticalTabsSidebar: View { let workspaceGroups: [WorkspaceGroup] let workspaceGroupById: [UUID: WorkspaceGroup] let workspaceGroupMenuSnapshot: WorkspaceGroupMenuSnapshot + let workspaceGroupCollapsedUnreadCountById: [UUID: Int] + let workspaceGroupCanMarkAllReadById: [UUID: Bool] + let workspaceGroupCanMarkAllUnreadById: [UUID: Bool] let workspaceRenderItems: [SidebarWorkspaceRenderItem] let visibleWorkspaceRowIds: [UUID] var workspaceIds: [UUID] { tabIds } } + private func workspaceGroupNotificationSummaries( + tabs: [Workspace], + workspaceGroups: [WorkspaceGroup] + ) -> ( + collapsedUnreadCountByGroupId: [UUID: Int], + canMarkAllReadByGroupId: [UUID: Bool], + canMarkAllUnreadByGroupId: [UUID: Bool] + ) { + let groupsById = Dictionary(uniqueKeysWithValues: workspaceGroups.map { ($0.id, $0) }) + let knownGroupIds = Set(groupsById.keys) + var childGroupIdsByParentId: [UUID: [UUID]] = [:] + for group in workspaceGroups { + guard let parentGroupId = group.parentGroupId, + parentGroupId != group.id, + knownGroupIds.contains(parentGroupId) else { + continue + } + childGroupIdsByParentId[parentGroupId, default: []].append(group.id) + } + + var directAggregateByGroupId: [UUID: WorkspaceGroupNotificationAggregate] = [:] + for workspace in tabs { + guard let groupId = workspace.groupId, + groupsById[groupId] != nil else { continue } + let unreadCount = sidebarUnread.unreadCount(forWorkspaceId: workspace.id) + directAggregateByGroupId[groupId, default: WorkspaceGroupNotificationAggregate()].memberCount += 1 + directAggregateByGroupId[groupId, default: WorkspaceGroupNotificationAggregate()].unreadNotificationCount += unreadCount + if unreadCount > 0 { + directAggregateByGroupId[groupId, default: WorkspaceGroupNotificationAggregate()].unreadWorkspaceCount += 1 + } + } + + var subtreeAggregateByGroupId: [UUID: WorkspaceGroupNotificationAggregate] = [:] + func subtreeAggregate(for groupId: UUID, visiting: inout Set) -> WorkspaceGroupNotificationAggregate { + if let cached = subtreeAggregateByGroupId[groupId] { + return cached + } + guard visiting.insert(groupId).inserted else { + return directAggregateByGroupId[groupId] ?? WorkspaceGroupNotificationAggregate() + } + var aggregate = directAggregateByGroupId[groupId] ?? WorkspaceGroupNotificationAggregate() + for childGroupId in childGroupIdsByParentId[groupId] ?? [] { + aggregate.add(subtreeAggregate(for: childGroupId, visiting: &visiting)) + } + visiting.remove(groupId) + subtreeAggregateByGroupId[groupId] = aggregate + return aggregate + } + + let workspaceIds = Set(tabs.map(\.id)) + var collapsedUnreadCountByGroupId: [UUID: Int] = [:] + var canMarkAllReadByGroupId: [UUID: Bool] = [:] + var canMarkAllUnreadByGroupId: [UUID: Bool] = [:] + for group in workspaceGroups { + var visiting: Set = [] + let aggregate = subtreeAggregate(for: group.id, visiting: &visiting) + let anchorExists = workspaceIds.contains(group.anchorWorkspaceId) + let anchorIsUnread = sidebarUnread.unreadCount(forWorkspaceId: group.anchorWorkspaceId) > 0 + let excludedUnreadWorkspaceCount = anchorExists && anchorIsUnread ? 1 : 0 + let excludedReadWorkspaceCount = anchorExists && !anchorIsUnread ? 1 : 0 + let unreadWorkspaceCount = max(0, aggregate.unreadWorkspaceCount - excludedUnreadWorkspaceCount) + let readWorkspaceCount = max( + 0, + aggregate.memberCount - aggregate.unreadWorkspaceCount - excludedReadWorkspaceCount + ) + + collapsedUnreadCountByGroupId[group.id] = aggregate.unreadNotificationCount + canMarkAllReadByGroupId[group.id] = unreadWorkspaceCount > 0 + canMarkAllUnreadByGroupId[group.id] = readWorkspaceCount > 0 + } + + return ( + collapsedUnreadCountByGroupId, + canMarkAllReadByGroupId, + canMarkAllUnreadByGroupId + ) + } + var body: some View { #if DEBUG let _ = { minimalModeInvalidationProbe.verticalTabsSidebarBody?() }() @@ -10414,6 +10492,10 @@ struct VerticalTabsSidebar: View { let workspaceGroupMenuSnapshot = WorkspaceGroupMenuSnapshot( items: workspaceGroups.map { WorkspaceGroupMenuSnapshot.Item(id: $0.id, name: $0.name) } ) + let workspaceGroupSummaryMaps = workspaceGroupNotificationSummaries( + tabs: tabs, + workspaceGroups: workspaceGroups + ) let workspaceRenderItems = SidebarWorkspaceRenderItem.renderItems( tabs: tabs, groupsById: workspaceGroupById @@ -10427,7 +10509,7 @@ struct VerticalTabsSidebar: View { scope: dropIndicatorScope, tabs: tabs, workspaceGroups: workspaceGroups, - visibleWorkspaceRowIds: visibleWorkspaceRowIds + workspaceRenderItems: workspaceRenderItems ) } ?? [] let renderContext = WorkspaceListRenderContext( @@ -10449,6 +10531,9 @@ struct VerticalTabsSidebar: View { workspaceGroups: workspaceGroups, workspaceGroupById: workspaceGroupById, workspaceGroupMenuSnapshot: workspaceGroupMenuSnapshot, + workspaceGroupCollapsedUnreadCountById: workspaceGroupSummaryMaps.collapsedUnreadCountByGroupId, + workspaceGroupCanMarkAllReadById: workspaceGroupSummaryMaps.canMarkAllReadByGroupId, + workspaceGroupCanMarkAllUnreadById: workspaceGroupSummaryMaps.canMarkAllUnreadByGroupId, workspaceRenderItems: workspaceRenderItems, visibleWorkspaceRowIds: visibleWorkspaceRowIds ) @@ -11873,16 +11958,18 @@ struct VerticalTabsSidebar: View { LazyVStack(spacing: tabRowSpacing) { ForEach(renderItems, id: \.id) { item in switch item { - case .groupHeader(let group, let memberWorkspaceIds): + case .groupHeader(let group, let memberCount, let depth): sidebarWorkspaceGroupHeader( group: group, - memberWorkspaceIds: memberWorkspaceIds, + memberCount: memberCount, + depth: depth, renderContext: renderContext, shouldCollectWorkspaceDropTargets: shouldCollectWorkspaceDropTargets, showModifierHoldHints: showModifierHoldHints ) - case .workspace(let tab): + case .workspace(let tab, let depth): workspaceRow( tab, + depth: depth, renderContext: renderContext, shouldCollectWorkspaceDropTargets: shouldCollectWorkspaceDropTargets ) @@ -12098,7 +12185,8 @@ struct VerticalTabsSidebar: View { SidebarWorkspaceReorderGroupSnapshot( id: $0.id, anchorWorkspaceId: $0.anchorWorkspaceId, - isPinned: $0.isPinned + isPinned: $0.isPinned, + parentGroupId: $0.parentGroupId ) }, targets: targets.map { @@ -12114,13 +12202,14 @@ struct VerticalTabsSidebar: View { } private func performWorkspaceReorderPlan(_ plan: SidebarWorkspaceReorderDropPlan) -> Bool { + let selectionBeforeReorder = selectedTabIds + let anchorWorkspaceIdBeforeReorder = SidebarWorkspaceSelectionSyncPolicy().anchorWorkspaceId( + existingAnchorIndex: lastSidebarSelectionIndex, + liveWorkspaceIds: tabManager.tabs.map(\.id) + ) + func syncSelectionAfterWorkspaceTreeMutation() { syncSidebarSelectionAfterWorkspaceReorder(preserving: selectionBeforeReorder, preferredAnchorWorkspaceId: anchorWorkspaceIdBeforeReorder) } switch plan.action { case .reorder(let targetIndex, let usesTopLevelRows, let explicitGroupId): - let selectionBeforeReorder = selectedTabIds - let anchorWorkspaceIdBeforeReorder = SidebarWorkspaceSelectionSyncPolicy().anchorWorkspaceId( - existingAnchorIndex: lastSidebarSelectionIndex, - liveWorkspaceIds: tabManager.tabs.map(\.id) - ) let didReorder = tabManager.reorderSidebarWorkspace( tabId: plan.draggedWorkspaceId, toIndex: targetIndex, @@ -12128,13 +12217,14 @@ struct VerticalTabsSidebar: View { usesTopLevelRows: usesTopLevelRows, explicitGroupId: explicitGroupId ) - syncSidebarSelectionAfterWorkspaceReorder( - preserving: selectionBeforeReorder, - preferredAnchorWorkspaceId: anchorWorkspaceIdBeforeReorder - ) + syncSelectionAfterWorkspaceTreeMutation() return didReorder case .crossWindow(insertionIndex: _, proposedInsertionIndex: let proposedInsertionIndex): return performCrossWindowWorkspaceDrop(plan: plan, proposedInsertionIndex: proposedInsertionIndex) + case .reparentGroup(let groupId, let parentGroupId): + let didReparent = tabManager.setWorkspaceGroupParent(groupId: groupId, parentGroupId: parentGroupId) + if didReparent { syncSelectionAfterWorkspaceTreeMutation() } + return didReparent } } @@ -12260,6 +12350,7 @@ struct VerticalTabsSidebar: View { @ViewBuilder private func workspaceRow( _ tab: Workspace, + depth: Int, renderContext: WorkspaceListRenderContext, shouldCollectWorkspaceDropTargets: Bool ) -> some View { @@ -12349,6 +12440,8 @@ struct VerticalTabsSidebar: View { focusWindow: true ) } + + let leadingIndent = CGFloat(max(depth, 0)) * SidebarWorkspaceGroupingMetrics.memberIndent let syncSidebarSelectionAfterBonsplitDrop: @MainActor () -> Void = { if let selectedId = tabManager.selectedTabId { lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } @@ -12400,7 +12493,7 @@ struct VerticalTabsSidebar: View { row .sidebarWorkspaceFrameAnchor(id: tab.id, isEnabled: shouldCollectWorkspaceDropTargets) - .padding(.leading, tab.groupId != nil ? SidebarWorkspaceGroupingMetrics.memberIndent : 0) + .padding(.leading, leadingIndent) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } @@ -15955,13 +16048,6 @@ struct SidebarTabDropDelegate: DropDelegate { return false } - guard fromIndex != targetIndex || explicitGroupId != nil else { -#if DEBUG - cmuxDebugLog("sidebar.drop.noop from=\(fromIndex) to=\(targetIndex)") -#endif - return true - } - #if DEBUG cmuxDebugLog("sidebar.drop.commit tab=\(draggedTabId.uuidString.prefix(5)) from=\(fromIndex) to=\(targetIndex)") #endif @@ -15981,7 +16067,7 @@ struct SidebarTabDropDelegate: DropDelegate { preserving: selectionBeforeReorder, preferredAnchorWorkspaceId: anchorWorkspaceIdBeforeReorder ) - return didReorder + return didReorder || fromIndex == targetIndex } private func usesTopLevelRowsForDrop( diff --git a/Sources/Mobile/MobileWorkspaceListObserver.swift b/Sources/Mobile/MobileWorkspaceListObserver.swift index 5456bd232660..79c78de4e94c 100644 --- a/Sources/Mobile/MobileWorkspaceListObserver.swift +++ b/Sources/Mobile/MobileWorkspaceListObserver.swift @@ -235,11 +235,11 @@ final class MobileWorkspaceListObserver { /// preview (notification id + timestamp). Folding it in means a new notification /// (or a cleared one) re-emits to the phone, which renders the preview + relative /// time. Workspaces with no notification are simply absent from the map. - private static func summaryHash( + static func summaryHash( for tabs: [Workspace], - groups: [WorkspaceGroup], + groups: [WorkspaceGroup] = [], selectedTabID: UUID?, - previewSignatures: [UUID: Int] + previewSignatures: [UUID: Int] = [:] ) -> Int { var hasher = Hasher() hasher.combine(tabs.count) @@ -254,6 +254,7 @@ final class MobileWorkspaceListObserver { hasher.combine(group.name) hasher.combine(group.isCollapsed) hasher.combine(group.isPinned) + hasher.combine(group.parentGroupId) hasher.combine(group.anchorWorkspaceId) } for workspace in tabs { @@ -289,20 +290,4 @@ final class MobileWorkspaceListObserver { } return hasher.finalize() } - - #if DEBUG - static func summaryHashForTesting( - tabs: [Workspace], - groups: [WorkspaceGroup] = [], - selectedTabID: UUID?, - previewSignatures: [UUID: Int] = [:] - ) -> Int { - summaryHash( - for: tabs, - groups: groups, - selectedTabID: selectedTabID, - previewSignatures: previewSignatures - ) - } - #endif } diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 4557c220d663..9e6e80be0d60 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -1862,6 +1862,12 @@ struct SessionWorkspaceGroupSnapshot: Codable, Sendable, Equatable { var id: UUID var name: String var isCollapsed: Bool + /// Parent group id for nested folders. Only meaningful within snapshots that + /// preserve group UUIDs; restore also records `parentGroupIndex`. + var parentGroupId: UUID? = nil + /// 0-based index of the parent group in `workspaceGroups`. Restore-stable + /// across any future group UUID remapping as long as snapshot order survives. + var parentGroupIndex: Int? = nil /// The workspace whose close dissolves the group. Only meaningful within /// a single app run; on restore, each workspace gets a fresh UUID. The /// loader prefers `anchorMemberIndex` (restore-stable) and treats this diff --git a/Sources/SessionPersistencePolicy+CrashStorage.swift b/Sources/SessionPersistencePolicy+CrashStorage.swift index e12e5297054d..f285d09bc8e1 100644 --- a/Sources/SessionPersistencePolicy+CrashStorage.swift +++ b/Sources/SessionPersistencePolicy+CrashStorage.swift @@ -273,7 +273,8 @@ extension SessionPersistencePolicy { let originalMembersByGroupId = Dictionary(grouping: originalWorkspaces, by: \.groupId) let keptMembersByGroupId = Dictionary(grouping: keptWorkspaces, by: \.groupId) let occupiedGroupIds = Set(keptMembersByGroupId.keys.compactMap { $0 }) - let pruned = groups.compactMap { group -> SessionWorkspaceGroupSnapshot? in + let groupsById = Dictionary(uniqueKeysWithValues: groups.map { ($0.id, $0) }) + var pruned = groups.compactMap { group -> SessionWorkspaceGroupSnapshot? in guard occupiedGroupIds.contains(group.id) else { return nil } let groupId = Optional(group.id) let originalMembers = originalMembersByGroupId[groupId] ?? [] @@ -291,6 +292,36 @@ extension SessionPersistencePolicy { copy.anchorWorkspaceId = keptMembers[newAnchorIndex].workspaceId return copy } + let survivingIds = Set(pruned.map(\.id)) + func originalParentId(for group: SessionWorkspaceGroupSnapshot) -> UUID? { + if let parentGroupIndex = group.parentGroupIndex, + groups.indices.contains(parentGroupIndex) { + return groups[parentGroupIndex].id + } + if let parentGroupId = group.parentGroupId, + groupsById[parentGroupId] != nil { + return parentGroupId + } + return nil + } + func survivingParentId(for group: SessionWorkspaceGroupSnapshot) -> UUID? { + var visited: Set = [group.id] + var cursor = originalParentId(for: group) + while let current = cursor { + guard visited.insert(current).inserted else { return nil } + if survivingIds.contains(current) { + return current + } + cursor = groupsById[current].flatMap { originalParentId(for: $0) } + } + return nil + } + let survivingIndexById = Dictionary(uniqueKeysWithValues: pruned.enumerated().map { ($0.element.id, $0.offset) }) + for index in pruned.indices { + let parentId = survivingParentId(for: pruned[index]) + pruned[index].parentGroupId = parentId + pruned[index].parentGroupIndex = parentId.flatMap { survivingIndexById[$0] } + } return pruned.isEmpty ? nil : pruned } diff --git a/Sources/Sidebar/SidebarState.swift b/Sources/Sidebar/SidebarState.swift index 0484848d676c..ca77813759d8 100644 --- a/Sources/Sidebar/SidebarState.swift +++ b/Sources/Sidebar/SidebarState.swift @@ -84,9 +84,19 @@ enum SidebarSelectedWorkspaceScrollPolicy { /// never from what the lazy layout happens to have realized. static func scrollTargetWorkspaceId( selectedWorkspaceId: UUID, - group: WorkspaceGroup? + group: WorkspaceGroup?, + groupsById: [UUID: WorkspaceGroup] = [:] ) -> UUID { - guard let group, group.isCollapsed else { return selectedWorkspaceId } - return group.anchorWorkspaceId + var cursor = group + var visited: Set = [] + var collapsedAnchorWorkspaceId: UUID? + while let current = cursor { + guard visited.insert(current.id).inserted else { return collapsedAnchorWorkspaceId ?? selectedWorkspaceId } + if current.isCollapsed { + collapsedAnchorWorkspaceId = current.anchorWorkspaceId + } + cursor = current.parentGroupId.flatMap { groupsById[$0] } + } + return collapsedAnchorWorkspaceId ?? selectedWorkspaceId } } diff --git a/Sources/SidebarDropIndicatorRowScopeResolver.swift b/Sources/SidebarDropIndicatorRowScopeResolver.swift new file mode 100644 index 000000000000..868d3b60c1b8 --- /dev/null +++ b/Sources/SidebarDropIndicatorRowScopeResolver.swift @@ -0,0 +1,46 @@ +import CmuxFoundation +import CmuxWorkspaces +import Foundation + +@MainActor +struct SidebarDropIndicatorRowScopeResolver { + let tabs: [Workspace] + let workspaceGroups: [WorkspaceGroup] + let workspaceRenderItems: [SidebarWorkspaceRenderItem] + let topLevelWorkspaceRowIds: [UUID] + + func rowIds(for scope: SidebarWorkspaceReorderDropIndicatorScope) -> [UUID] { + switch scope { + case .raw: + return tabs.map(\.id) + case .topLevel: + return topLevelWorkspaceRowIds + case .group(let groupId): + return groupRowIds(for: groupId) + } + } + + private func groupRowIds(for groupId: UUID) -> [UUID] { + guard workspaceGroups.contains(where: { $0.id == groupId }), + let rootIndex = workspaceRenderItems.firstIndex(where: { item in + if case .groupHeader(let group, _, _) = item { + return group.id == groupId + } + return false + }) else { + return [] + } + + let rootDepth = workspaceRenderItems[rootIndex].depth + var rowIds: [UUID] = [] + rowIds.reserveCapacity(workspaceRenderItems.count - rootIndex) + for index in rootIndex.. Void let onTapPlus: () -> Void let onRunResolvedItem: (CmuxResolvedConfigMenuAction) -> Void + let onNewSubfolder: () -> Void let onRename: () -> Void let onTogglePinned: () -> Void let onMarkRead: () -> Void @@ -327,6 +328,13 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { .onDisappear { rowInteractionState.contextMenuDidDisappear() } + Button( + String( + localized: "workspaceGroup.contextMenu.newSubfolder", + defaultValue: "New Folder" + ), + action: onNewSubfolder + ) Divider() Button( String( diff --git a/Sources/SidebarWorkspaceRenderChildRow.swift b/Sources/SidebarWorkspaceRenderChildRow.swift new file mode 100644 index 000000000000..2f9fc7e6623e --- /dev/null +++ b/Sources/SidebarWorkspaceRenderChildRow.swift @@ -0,0 +1,7 @@ +import CmuxWorkspaces + +@MainActor +enum SidebarWorkspaceRenderChildRow { + case group(WorkspaceGroup) + case workspace(Workspace) +} diff --git a/Sources/SidebarWorkspaceRenderItem.swift b/Sources/SidebarWorkspaceRenderItem.swift index a759484ec158..e8145e6042fc 100644 --- a/Sources/SidebarWorkspaceRenderItem.swift +++ b/Sources/SidebarWorkspaceRenderItem.swift @@ -4,69 +4,152 @@ import Foundation /// One drawable item in the workspace sidebar. @MainActor enum SidebarWorkspaceRenderItem { - case groupHeader(WorkspaceGroup, memberWorkspaceIds: [UUID]) - case workspace(Workspace) + case groupHeader(WorkspaceGroup, memberCount: Int, depth: Int) + case workspace(Workspace, depth: Int) var id: SidebarWorkspaceRenderItemID { switch self { - case .groupHeader(let group, _): + case .groupHeader(let group, _, _): return .group(group.id) - case .workspace(let workspace): + case .workspace(let workspace, _): return .workspace(workspace.id) } } var rowWorkspaceId: UUID { switch self { - case .groupHeader(let group, _): + case .groupHeader(let group, _, _): return group.anchorWorkspaceId - case .workspace(let workspace): + case .workspace(let workspace, _): return workspace.id } } + var depth: Int { + switch self { + case .groupHeader(_, _, let depth): + return depth + case .workspace(_, let depth): + return depth + } + } + static func renderItems( tabs: [Workspace], groupsById: [UUID: WorkspaceGroup] ) -> [SidebarWorkspaceRenderItem] { guard !tabs.isEmpty else { return [] } - var memberWorkspaceIdsByGroupId: [UUID: [UUID]] = [:] + let groups = Array(groupsById.values) + var anchorGroupByWorkspaceId: [UUID: WorkspaceGroup] = [:] + for group in groups where anchorGroupByWorkspaceId[group.anchorWorkspaceId] == nil { + anchorGroupByWorkspaceId[group.anchorWorkspaceId] = group + } + let tabIndexById = Dictionary(uniqueKeysWithValues: tabs.enumerated().map { ($0.element.id, $0.offset) }) + let renderableGroupIds = Set(anchorGroupByWorkspaceId.compactMap { anchorWorkspaceId, group in + tabIndexById[anchorWorkspaceId] == nil ? nil : group.id + }) + let knownGroupIds = Set(groupsById.keys) + var childGroupsByParentId: [UUID?: [WorkspaceGroup]] = [:] + var parentGroupIdByGroupId: [UUID: UUID?] = [:] + for group in groups { + let parentId: UUID? = { + guard let parentGroupId = group.parentGroupId, + parentGroupId != group.id, + knownGroupIds.contains(parentGroupId) else { + return nil + } + return parentGroupId + }() + parentGroupIdByGroupId[group.id] = parentId + childGroupsByParentId[parentId, default: []].append(group) + } + for parentId in Array(childGroupsByParentId.keys) { + childGroupsByParentId[parentId]?.sort { + (tabIndexById[$0.anchorWorkspaceId] ?? Int.max) < + (tabIndexById[$1.anchorWorkspaceId] ?? Int.max) + } + } + var directMemberCountByGroupId: [UUID: Int] = [:] for tab in tabs { if let gid = tab.groupId { - memberWorkspaceIdsByGroupId[gid, default: []].append(tab.id) + directMemberCountByGroupId[gid, default: 0] += 1 } } - var items: [SidebarWorkspaceRenderItem] = [] - items.reserveCapacity(tabs.count + groupsById.count) - var lastEmittedGroupId: UUID? = nil - var emittedHeaders: Set = [] - var collapsedByGroupId: [UUID: Bool] = [:] - var skipChildrenUntilNextGroup = false - for tab in tabs { - let groupId = tab.groupId - if groupId != lastEmittedGroupId { - lastEmittedGroupId = groupId - skipChildrenUntilNextGroup = false - if let groupId, let group = groupsById[groupId] { - if !emittedHeaders.contains(groupId) { - let memberWorkspaceIds = memberWorkspaceIdsByGroupId[groupId] ?? [] - items.append(.groupHeader(group, memberWorkspaceIds: memberWorkspaceIds)) - emittedHeaders.insert(groupId) - collapsedByGroupId[groupId] = group.isCollapsed - } - // If legacy reorder paths ever leave a group's members in - // two runs, keep honoring the same collapse decision. - skipChildrenUntilNextGroup = collapsedByGroupId[groupId] ?? false + var subtreeMemberCountByGroupId: [UUID: Int] = [:] + func subtreeMemberCount(for groupId: UUID, visiting: inout Set) -> Int { + if let cached = subtreeMemberCountByGroupId[groupId] { + return cached + } + guard visiting.insert(groupId).inserted else { return directMemberCountByGroupId[groupId] ?? 0 } + var count = directMemberCountByGroupId[groupId] ?? 0 + for childGroup in childGroupsByParentId[Optional(groupId)] ?? [] { + count += subtreeMemberCount(for: childGroup.id, visiting: &visiting) + } + visiting.remove(groupId) + subtreeMemberCountByGroupId[groupId] = count + return count + } + + func normalizedRenderableParentGroupId(for groupId: UUID) -> UUID? { + var visited: Set = [groupId] + var cursor = parentGroupIdByGroupId[groupId] ?? nil + while let current = cursor { + guard visited.insert(current).inserted else { return nil } + if renderableGroupIds.contains(current) { + return current } + cursor = parentGroupIdByGroupId[current] ?? nil } - // Anchor workspaces are represented exclusively by the group header. - if let groupId, let group = groupsById[groupId], group.anchorWorkspaceId == tab.id { + return nil + } + + var childRowsByParentId: [UUID?: [SidebarWorkspaceRenderChildRow]] = [:] + for tab in tabs { + if let anchoredGroup = anchorGroupByWorkspaceId[tab.id] { + childRowsByParentId[ + normalizedRenderableParentGroupId(for: anchoredGroup.id), + default: [] + ].append(.group(anchoredGroup)) continue } - if groupId == nil || !skipChildrenUntilNextGroup { - items.append(.workspace(tab)) + if let groupId = tab.groupId, let group = groupsById[groupId] { + if group.anchorWorkspaceId == tab.id { + continue + } + let rowParentId = renderableGroupIds.contains(groupId) + ? Optional(groupId) + : normalizedRenderableParentGroupId(for: groupId) + childRowsByParentId[rowParentId, default: []].append(.workspace(tab)) + } else { + childRowsByParentId[nil, default: []].append(.workspace(tab)) + } + } + + var items: [SidebarWorkspaceRenderItem] = [] + items.reserveCapacity(tabs.count + groupsById.count) + var emittedHeaders: Set = [] + + func appendGroup(_ group: WorkspaceGroup, depth: Int) { + guard emittedHeaders.insert(group.id).inserted else { return } + var visiting: Set = [] + let memberCount = subtreeMemberCount(for: group.id, visiting: &visiting) + items.append(.groupHeader(group, memberCount: memberCount, depth: depth)) + guard !group.isCollapsed else { return } + appendChildren(of: group.id, depth: depth + 1) + } + + func appendChildren(of parentGroupId: UUID?, depth: Int) { + for row in childRowsByParentId[parentGroupId] ?? [] { + switch row { + case .group(let anchoredGroup): + appendGroup(anchoredGroup, depth: depth) + case .workspace(let tab): + items.append(.workspace(tab, depth: depth)) + } } } + + appendChildren(of: nil, depth: 0) return items } } diff --git a/Sources/TabItemView+WorkspaceGroups.swift b/Sources/TabItemView+WorkspaceGroups.swift index 2d186c8516bd..615b0684f4e9 100644 --- a/Sources/TabItemView+WorkspaceGroups.swift +++ b/Sources/TabItemView+WorkspaceGroups.swift @@ -101,6 +101,10 @@ extension TabItemView { func promptNewWorkspaceGroup(workspaceIds: [UUID]) { guard !workspaceIds.isEmpty else { return } - tabManager.createWorkspaceGroup(name: "", childWorkspaceIds: workspaceIds) + tabManager.createWorkspaceGroup( + name: "", + childWorkspaceIds: workspaceIds, + parentGroupId: tabManager.commonWorkspaceGroupId(for: workspaceIds) + ) } } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index a54ce901b309..b89a28cf4d37 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1759,6 +1759,7 @@ class TabManager: ObservableObject { func createWorkspaceGroup( name: String, childWorkspaceIds: [UUID] = [], + parentGroupId: UUID? = nil, anchorWorkingDirectory: String? = nil, selectAnchor: Bool = true, collapseSidebarSelection: Bool = true @@ -1766,6 +1767,7 @@ class TabManager: ObservableObject { workspaceGrouping.createWorkspaceGroup( name: name, childWorkspaceIds: childWorkspaceIds, + parentGroupId: parentGroupId, anchorWorkingDirectory: anchorWorkingDirectory, selectAnchor: selectAnchor, collapseSidebarSelection: collapseSidebarSelection @@ -1849,6 +1851,35 @@ class TabManager: ObservableObject { workspaceGrouping.setWorkspaceGroupAnchor(groupId: groupId, workspaceId: workspaceId) } + @discardableResult + func setWorkspaceGroupParent(groupId: UUID, parentGroupId: UUID?) -> Bool { + workspaceGrouping.setWorkspaceGroupParent(groupId: groupId, parentGroupId: parentGroupId) + } + + func canSetWorkspaceGroupParent(groupId: UUID, parentGroupId: UUID?) -> Bool { + workspaceGrouping.canSetWorkspaceGroupParent(groupId: groupId, parentGroupId: parentGroupId) + } + + func workspaceGroupSubtreeWorkspaceIds(groupId: UUID) -> [UUID] { + workspaces.workspaceGroupSubtreeWorkspaceIds(groupId: groupId) + } + + func commonWorkspaceGroupId(for workspaceIds: [UUID]) -> UUID? { + guard !workspaceIds.isEmpty else { return nil } + let workspaceById = Dictionary(uniqueKeysWithValues: tabs.map { ($0.id, $0) }) + var commonGroupId: UUID? + for workspaceId in workspaceIds { + guard let groupId = workspaceById[workspaceId]?.groupId else { + return nil + } + if let currentGroupId = commonGroupId, currentGroupId != groupId { + return nil + } + commonGroupId = groupId + } + return commonGroupId.flatMap { id in workspaceGroups.contains { $0.id == id } ? id : nil } + } + func moveWorkspaceGroup(groupId: UUID, toIndex targetIndex: Int) { workspaceGrouping.moveWorkspaceGroup(groupId: groupId, toIndex: targetIndex) } @@ -5555,6 +5586,7 @@ extension TabManager { hasher.combine(group.name) hasher.combine(group.isCollapsed) hasher.combine(group.isPinned) + hasher.combine(group.parentGroupId) hasher.combine(group.anchorWorkspaceId) hasher.combine(group.customColor ?? "") hasher.combine(group.iconSymbol ?? "") @@ -5847,8 +5879,6 @@ extension TabManager { restorableTabs.firstIndex(where: { $0.id == selectedTabId }) } let occupiedGroupIds = Set(restorableTabs.compactMap(\.groupId)) - // Build a per-group ordered list of restorable member IDs so we can - // record the anchor's index (restore-stable across UUID rotation). let restorableMembersByGroupId: [UUID: [UUID]] = { var map: [UUID: [UUID]] = [:] for tab in restorableTabs { @@ -5858,16 +5888,35 @@ extension TabManager { } return map }() + let groupsById = Dictionary(uniqueKeysWithValues: workspaceGroups.map { ($0.id, $0) }) + func nearestRestorableParentGroupId(for group: WorkspaceGroup) -> UUID? { + var visited: Set = [group.id] + var cursor = group.parentGroupId + while let parentId = cursor, visited.insert(parentId).inserted { + if occupiedGroupIds.contains(parentId) { return parentId } + cursor = groupsById[parentId]?.parentGroupId + } + return nil + } let groupSnapshots: [SessionWorkspaceGroupSnapshot]? = { + let restorableGroupIndexById = Dictionary( + uniqueKeysWithValues: workspaceGroups + .filter { occupiedGroupIds.contains($0.id) } + .enumerated() + .map { ($0.element.id, $0.offset) } + ) let snapshots = workspaceGroups .filter { occupiedGroupIds.contains($0.id) } .map { group in let memberIds = restorableMembersByGroupId[group.id] ?? [] let anchorIndex = memberIds.firstIndex(of: group.anchorWorkspaceId) + let parentGroupId = nearestRestorableParentGroupId(for: group) return SessionWorkspaceGroupSnapshot( id: group.id, name: group.name, isCollapsed: group.isCollapsed, + parentGroupId: parentGroupId, + parentGroupIndex: parentGroupId.flatMap { restorableGroupIndexById[$0] }, anchorWorkspaceId: group.anchorWorkspaceId, anchorMemberIndex: anchorIndex, isPinned: group.isPinned, @@ -5974,8 +6023,6 @@ extension TabManager { newSelectedId = newTabs.first?.id } - // Single atomic assignment of @Published properties so SwiftUI observers - // never see an intermediate state with empty tabs or nil selection. tabs = newTabs let restoredGroups: [WorkspaceGroup] = { guard let groupSnapshots = snapshot.workspaceGroups else { return [] } @@ -5989,14 +6036,9 @@ extension TabManager { return map }() var seen: Set = [] - return groupSnapshots.compactMap { groupSnapshot in + let restoredPairs: [(snapshot: SessionWorkspaceGroupSnapshot, group: WorkspaceGroup)] = groupSnapshots.compactMap { groupSnapshot in guard let members = workspaceIdsByGroupId[groupSnapshot.id], !members.isEmpty, seen.insert(groupSnapshot.id).inserted else { return nil } - // Resolve anchor: prefer the restore-stable index (since each - // restored workspace gets a fresh UUID, the old - // anchorWorkspaceId rarely matches). Fall back to the in-process - // UUID hint, then to "first member by tab order" for very old - // snapshots that pre-date both fields. let anchorId: UUID = { if let index = groupSnapshot.anchorMemberIndex, members.indices.contains(index) { @@ -6007,16 +6049,75 @@ extension TabManager { } return members[0] }() - return WorkspaceGroup( + let group = WorkspaceGroup( id: groupSnapshot.id, name: groupSnapshot.name, isCollapsed: groupSnapshot.isCollapsed, isPinned: groupSnapshot.isPinned ?? false, + parentGroupId: nil, anchorWorkspaceId: anchorId, customColor: groupSnapshot.customColor, iconSymbol: groupSnapshot.iconSymbol ) + return (groupSnapshot, group) + } + let restoredGroupIds = Set(restoredPairs.map { $0.group.id }) + var restored = restoredPairs.map { $0.group } + // Resolve each restored group's parent to the nearest ancestor that + // also restored. When an intermediate folder is dropped (none of its + // members restored), walk up the original parent chain so a surviving + // deeper folder re-attaches to its closest surviving ancestor instead + // of flattening to the root — matching the crash-storage pruning + // path's `survivingParentId`. + let groupSnapshotsById = Dictionary( + groupSnapshots.map { ($0.id, $0) }, + uniquingKeysWith: { first, _ in first } + ) + func immediateParentId(of groupSnapshot: SessionWorkspaceGroupSnapshot) -> UUID? { + if let parentGroupIndex = groupSnapshot.parentGroupIndex, + groupSnapshots.indices.contains(parentGroupIndex) { + return groupSnapshots[parentGroupIndex].id + } + if let parentGroupId = groupSnapshot.parentGroupId, + groupSnapshotsById[parentGroupId] != nil { + return parentGroupId + } + return nil + } + func resolvedParentGroupId(for index: Int) -> UUID? { + let groupSnapshot = restoredPairs[index].snapshot + var visited: Set = [groupSnapshot.id] + var cursor = immediateParentId(of: groupSnapshot) + while let current = cursor { + guard visited.insert(current).inserted else { return nil } + if restoredGroupIds.contains(current) { + return current + } + cursor = groupSnapshotsById[current].flatMap { immediateParentId(of: $0) } + } + return nil + } + var parentByGroupId = Dictionary(uniqueKeysWithValues: restored.map { ($0.id, $0.parentGroupId) }) + func wouldCreateParentCycle(groupId: UUID, parentGroupId: UUID?) -> Bool { + guard let parentGroupId else { return false } + guard parentGroupId != groupId else { return true } + var visited: Set = [] + var cursor: UUID? = parentGroupId + while let current = cursor { + guard visited.insert(current).inserted else { return true } + if current == groupId { return true } + cursor = parentByGroupId[current] ?? nil + } + return false + } + for index in restored.indices { + let parentGroupId = resolvedParentGroupId(for: index) + if !wouldCreateParentCycle(groupId: restored[index].id, parentGroupId: parentGroupId) { + restored[index].parentGroupId = parentGroupId + parentByGroupId[restored[index].id] = parentGroupId + } } + return restored }() // Clear any group references on restored workspaces that no longer correspond // to a known group (older snapshots, manual edits, etc.). diff --git a/Sources/TerminalController+ControlWorkspaceGroupContext.swift b/Sources/TerminalController+ControlWorkspaceGroupContext.swift index 433b2aa17c1e..8f9f2113b8f0 100644 --- a/Sources/TerminalController+ControlWorkspaceGroupContext.swift +++ b/Sources/TerminalController+ControlWorkspaceGroupContext.swift @@ -42,6 +42,7 @@ extension TerminalController: ControlWorkspaceGroupContext { name: group.name, isCollapsed: group.isCollapsed, isPinned: group.isPinned, + parentGroupID: group.parentGroupId, anchorWorkspaceID: group.anchorWorkspaceId, customColor: group.customColor, iconSymbol: group.iconSymbol, @@ -127,6 +128,7 @@ extension TerminalController: ControlWorkspaceGroupContext { let createdGroupId = tabManager.createWorkspaceGroup( name: name, childWorkspaceIds: childIds, + parentGroupId: tabManager.commonWorkspaceGroupId(for: childIds), anchorWorkingDirectory: cwd, selectAnchor: false, collapseSidebarSelection: false @@ -325,6 +327,18 @@ extension TerminalController: ControlWorkspaceGroupContext { guard let current = tabManager.workspaceGroups.firstIndex(where: { $0.id == groupID }) else { return false } + let source = tabManager.workspaceGroups[current] + func peerIndex(groupId: UUID) -> Int? { + guard let index = tabManager.workspaceGroups.firstIndex(where: { $0.id == groupId }) else { + return nil + } + let peer = tabManager.workspaceGroups[index] + guard peer.parentGroupId == source.parentGroupId, + peer.isPinned == source.isPinned else { + return nil + } + return index + } // moveWorkspaceGroup interprets toIndex as the FINAL position the group // should occupy. before/after refer to a peer's CURRENT index, so when // the source comes before the peer in the original order, removing the @@ -334,12 +348,12 @@ extension TerminalController: ControlWorkspaceGroupContext { if let toIndex { return toIndex } - if let beforeId = beforeGroupID, - let beforeIndex = tabManager.workspaceGroups.firstIndex(where: { $0.id == beforeId }) { + if let beforeId = beforeGroupID { + guard let beforeIndex = peerIndex(groupId: beforeId) else { return nil } return current < beforeIndex ? beforeIndex - 1 : beforeIndex } - if let afterId = afterGroupID, - let afterIndex = tabManager.workspaceGroups.firstIndex(where: { $0.id == afterId }) { + if let afterId = afterGroupID { + guard let afterIndex = peerIndex(groupId: afterId) else { return nil } return current < afterIndex ? afterIndex : afterIndex + 1 } return nil diff --git a/Sources/TerminalController+MobileWorkspaceList.swift b/Sources/TerminalController+MobileWorkspaceList.swift index f2a69a8e2183..bab4bc890cbf 100644 --- a/Sources/TerminalController+MobileWorkspaceList.swift +++ b/Sources/TerminalController+MobileWorkspaceList.swift @@ -407,6 +407,7 @@ extension TerminalController { "name": group.name, "is_collapsed": group.isCollapsed, "is_pinned": group.isPinned, + "parent_group_id": v2OrNull(group.parentGroupId?.uuidString), "anchor_workspace_id": group.anchorWorkspaceId.uuidString, "member_workspace_ids": memberIDsByGroup[group.id] ?? [] ] diff --git a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift index b162a5f3177c..e08b15ba3cf0 100644 --- a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift +++ b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift @@ -8,7 +8,8 @@ extension VerticalTabsSidebar { @ViewBuilder func sidebarWorkspaceGroupHeader( group: WorkspaceGroup, - memberWorkspaceIds: [UUID], + memberCount: Int, + depth: Int, renderContext: WorkspaceListRenderContext, shouldCollectWorkspaceDropTargets: Bool, showModifierHoldHints: Bool @@ -26,22 +27,19 @@ extension VerticalTabsSidebar { let newWorkspacePlacement = resolvedConfig?.newWorkspacePlacement let anchorUnreadCount: Int = { if group.isCollapsed { - return memberWorkspaceIds.reduce(0) { partial, workspaceId in - partial + notificationStore.unreadCount(forTabId: workspaceId) - } + return renderContext.workspaceGroupCollapsedUnreadCountById[group.id, default: 0] } - return notificationStore.unreadCount(forTabId: group.anchorWorkspaceId) + return sidebarUnread.unreadCount(forWorkspaceId: group.anchorWorkspaceId) }() let anchorIds = [group.anchorWorkspaceId] - let canMarkAnchorRead = notificationStore.canMarkWorkspaceRead(forTabIds: anchorIds) - let canMarkAnchorUnread = notificationStore.canMarkWorkspaceUnread(forTabIds: anchorIds) + let canMarkAnchorRead = sidebarUnread.canMarkWorkspaceRead(forWorkspaceIds: anchorIds) + let canMarkAnchorUnread = sidebarUnread.canMarkWorkspaceUnread(forWorkspaceIds: anchorIds) let anchorHasLatestNotification = notificationStore.latestNotification(forTabId: group.anchorWorkspaceId) != nil // "Mark all workspaces in group" targets the contained workspaces only, // never the anchor: the anchor is the group's own row, whose read status // is owned by the separate "Mark Group as Read/Unread" actions. - let nonAnchorMemberIds = memberWorkspaceIds.filter { $0 != group.anchorWorkspaceId } - let canMarkAllRead = notificationStore.canMarkWorkspaceRead(forTabIds: nonAnchorMemberIds) - let canMarkAllUnread = notificationStore.canMarkWorkspaceUnread(forTabIds: nonAnchorMemberIds) + let canMarkAllRead = renderContext.workspaceGroupCanMarkAllReadById[group.id, default: false] + let canMarkAllUnread = renderContext.workspaceGroupCanMarkAllUnreadById[group.id, default: false] let anchorIndex = renderContext.tabIndexById[group.anchorWorkspaceId] ?? 0 let shortcutDigit = WorkspaceShortcutMapper.digitForWorkspace( at: anchorIndex, @@ -78,7 +76,7 @@ extension VerticalTabsSidebar { isCollapsed: group.isCollapsed, isPinned: group.isPinned, isAnchorActive: isAnchorActive, - memberCount: memberWorkspaceIds.count, + memberCount: memberCount, anchorUnreadCount: anchorUnreadCount, canMarkRead: canMarkAnchorRead, canMarkUnread: canMarkAnchorUnread, @@ -127,6 +125,15 @@ extension VerticalTabsSidebar { groupId: groupId ) }, + onNewSubfolder: { [weak tabManager, groupId = group.id, anchorId = group.anchorWorkspaceId] in + guard let tabManager else { return } + let anchorCwd = tabManager.tabs.first(where: { $0.id == anchorId })?.currentDirectory + tabManager.createWorkspaceGroup( + name: "", + parentGroupId: groupId, + anchorWorkingDirectory: anchorCwd + ) + }, onRename: { [weak tabManager, groupId = group.id, currentName = group.name] in guard let tabManager else { return } presentSidebarWorkspaceGroupRenamePrompt( @@ -152,7 +159,7 @@ extension VerticalTabsSidebar { // Resolve members live at action time: the header is .equatable() // and closures are excluded from ==, so a captured ID list could // go stale across a same-count membership swap. - let ids = tabManager.tabs.compactMap { $0.groupId == groupId && $0.id != anchorId ? $0.id : nil } + let ids = tabManager.workspaceGroupSubtreeWorkspaceIds(groupId: groupId).filter { $0 != anchorId } // Only touch members that are actually unread, so we never run // notification teardown on already-read workspaces. for id in ids where notificationStore.canMarkWorkspaceRead(forTabIds: [id]) { @@ -161,7 +168,7 @@ extension VerticalTabsSidebar { }, onMarkAllUnread: { [weak tabManager, weak notificationStore, groupId = group.id, anchorId = group.anchorWorkspaceId] in guard let tabManager, let notificationStore else { return } - let ids = tabManager.tabs.compactMap { $0.groupId == groupId && $0.id != anchorId ? $0.id : nil } + let ids = tabManager.workspaceGroupSubtreeWorkspaceIds(groupId: groupId).filter { $0 != anchorId } // Only mark members that are not already unread. Calling // markUnread on an already-unread member would set its manual // unread flag, which a later notification dismissal cannot @@ -173,7 +180,7 @@ extension VerticalTabsSidebar { onUngroup: { [weak tabManager, groupId = group.id] in tabManager?.ungroupWorkspaceGroup(groupId: groupId) }, - onDelete: { [weak tabManager, groupId = group.id, groupName = group.name, memberCount = memberWorkspaceIds.count] in + onDelete: { [weak tabManager, groupId = group.id, groupName = group.name, memberCount] in guard let tabManager else { return } let otherMemberCount = max(memberCount - 1, 0) guard confirmDeleteWorkspaceGroup(groupName: groupName, otherMemberCount: otherMemberCount) else { return } @@ -195,5 +202,6 @@ extension VerticalTabsSidebar { id: group.anchorWorkspaceId, isEnabled: shouldCollectWorkspaceDropTargets ) + .padding(.leading, CGFloat(max(depth, 0)) * SidebarWorkspaceGroupingMetrics.memberIndent) } } diff --git a/Sources/WorkspaceGroupNotificationAggregate.swift b/Sources/WorkspaceGroupNotificationAggregate.swift new file mode 100644 index 000000000000..81304a1ec2f2 --- /dev/null +++ b/Sources/WorkspaceGroupNotificationAggregate.swift @@ -0,0 +1,11 @@ +struct WorkspaceGroupNotificationAggregate { + var memberCount: Int = 0 + var unreadWorkspaceCount: Int = 0 + var unreadNotificationCount: Int = 0 + + mutating func add(_ other: WorkspaceGroupNotificationAggregate) { + memberCount += other.memberCount + unreadWorkspaceCount += other.unreadWorkspaceCount + unreadNotificationCount += other.unreadNotificationCount + } +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 2d50014f8fd1..8773402ad6ea 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -468,6 +468,7 @@ A5001002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001012 /* ContentView.swift */; }; C0DE32470000000000000001 /* ContentViewIdentifierCopyCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE32470000000000000002 /* ContentViewIdentifierCopyCommands.swift */; }; D10000000000000000B00000 /* ControlSocketReadinessUITestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000B00001 /* ControlSocketReadinessUITestSupport.swift */; }; + D8FBD4E0BD6E4CED9D906CBA /* ControlWorkspaceGroupCreateParentInferenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC5355A1BF10411A88950AEC /* ControlWorkspaceGroupCreateParentInferenceTests.swift */; }; C65930010000000000000002 /* CrashDiagnosticSessionPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C65930010000000000000001 /* CrashDiagnosticSessionPolicyTests.swift */; }; C57B00080000000000000001 /* CustomSidebarPaneDataContextCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C57B00080000000000000002 /* CustomSidebarPaneDataContextCache.swift */; }; C57B00010000000000000001 /* CustomSidebarPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C57B00010000000000000002 /* CustomSidebarPanel.swift */; }; @@ -929,6 +930,7 @@ A5001670 /* SessionRestoredTerminalCommandStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001671 /* SessionRestoredTerminalCommandStore.swift */; }; A50016B1A1B2C3D4E5F60718 /* SessionSnapshotDebugBenchmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50016B0A1B2C3D4E5F60718 /* SessionSnapshotDebugBenchmark.swift */; }; F5320002A1B2C3D4E5F60718 /* SessionTranscriptTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5320003A1B2C3D4E5F60718 /* SessionTranscriptTypes.swift */; }; + C6611001C6611001C6611001 /* SessionWorkspaceGroupSnapshotParentChainTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6611002C6611002C6611002 /* SessionWorkspaceGroupSnapshotParentChainTests.swift */; }; 583A675AA1224E8D82A44883 /* SetAutoTitleSocketTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92AB6D1CB714189A8F167E5 /* SetAutoTitleSocketTests.swift */; }; D10000000000000000A00020 /* SettingsAccountJSONResetBehaviorUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000A00021 /* SettingsAccountJSONResetBehaviorUITests.swift */; }; D10000000000000000A00012 /* SettingsAppBehaviorUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000A00013 /* SettingsAppBehaviorUITests.swift */; }; @@ -963,6 +965,8 @@ D7AB34300000000000000003 /* SidebarBonsplitTabWorkspaceDropOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */; }; D7AB34310000000000000001 /* SidebarBonsplitWorkspaceRowDropModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34310000000000000002 /* SidebarBonsplitWorkspaceRowDropModifier.swift */; }; EA1F00000000000000000003 /* SidebarDirectoryText.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1F00000000000000000004 /* SidebarDirectoryText.swift */; }; + C9A57231C9A57231C9A57231 /* SidebarDropIndicatorRowScopeResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57232C9A57232C9A57232 /* SidebarDropIndicatorRowScopeResolver.swift */; }; + C9A57241C9A57241C9A57241 /* SidebarDropIndicatorRowScopeResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57242C9A57242C9A57242 /* SidebarDropIndicatorRowScopeResolverTests.swift */; }; B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */; }; 51D800000000000000000001 /* SidebarIdentifierFormattingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D800000000000000000002 /* SidebarIdentifierFormattingTests.swift */; }; 5732A0115732A0115732A011 /* SidebarLazyContractProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5732B0115732B0115732B011 /* SidebarLazyContractProbe.swift */; }; @@ -992,8 +996,11 @@ 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 */; }; + D7AB34310000000000000005 /* SidebarWorkspaceGroupReparentDropPlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34310000000000000006 /* SidebarWorkspaceGroupReparentDropPlannerTests.swift */; }; + C9A57221C9A57221C9A57221 /* SidebarWorkspaceRenderChildRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57222C9A57222C9A57222 /* SidebarWorkspaceRenderChildRow.swift */; }; C9A57201C9A57201C9A57201 /* SidebarWorkspaceRenderItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57202C9A57202C9A57202 /* SidebarWorkspaceRenderItem.swift */; }; C9A57211C9A57211C9A57211 /* SidebarWorkspaceRenderItemID.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57212C9A57212C9A57212 /* SidebarWorkspaceRenderItemID.swift */; }; + AEE71AD3C9354EE3B8B9CC9A /* SidebarWorkspaceRenderItemStaleAnchorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EBD266146B478FAE0D4745 /* SidebarWorkspaceRenderItemStaleAnchorTests.swift */; }; C9A5760DC9A5760DC9A5760D /* SidebarWorkspaceReorderDropOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A5760EC9A5760EC9A5760E /* SidebarWorkspaceReorderDropOverlay.swift */; }; C9A57305C9A57305C9A57305 /* SidebarWorkspaceReorderDropOverlayHitTestingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57306C9A57306C9A57306 /* SidebarWorkspaceReorderDropOverlayHitTestingTests.swift */; }; C9A57607C9A57607C9A57607 /* SidebarWorkspaceReorderDropOverlayTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57608C9A57608C9A57608 /* SidebarWorkspaceReorderDropOverlayTarget.swift */; }; @@ -1258,6 +1265,7 @@ C9A57203C9A57203C9A57203 /* WorkspaceGroupMenuSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57204C9A57204C9A57204 /* WorkspaceGroupMenuSnapshot.swift */; }; C9A57206C9A57206C9A57206 /* WorkspaceGroupMoveToMenuState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57205C9A57205C9A57205 /* WorkspaceGroupMoveToMenuState.swift */; }; C9A57401C9A57401C9A57401 /* WorkspaceGroupMoveToMenuStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57402C9A57402C9A57402 /* WorkspaceGroupMoveToMenuStateTests.swift */; }; + C9A57223C9A57223C9A57223 /* WorkspaceGroupNotificationAggregate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57224C9A57224C9A57224 /* WorkspaceGroupNotificationAggregate.swift */; }; C9A57001C9A57001C9A57001 /* WorkspaceGroupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57002C9A57002C9A57002 /* WorkspaceGroupTests.swift */; }; E3B7A4000000000000000007 /* WorkspaceIndicatorStyle+Display.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3B7A4000000000000000008 /* WorkspaceIndicatorStyle+Display.swift */; }; CA52B0180000000000000000 /* WorkspaceLayoutMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA52C0180000000000000000 /* WorkspaceLayoutMode.swift */; }; @@ -1782,6 +1790,7 @@ A5001012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; C0DE32470000000000000002 /* ContentViewIdentifierCopyCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentViewIdentifierCopyCommands.swift; sourceTree = ""; }; D10000000000000000B00001 /* ControlSocketReadinessUITestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlSocketReadinessUITestSupport.swift; sourceTree = ""; }; + BC5355A1BF10411A88950AEC /* ControlWorkspaceGroupCreateParentInferenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlWorkspaceGroupCreateParentInferenceTests.swift; sourceTree = ""; }; C65930010000000000000001 /* CrashDiagnosticSessionPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashDiagnosticSessionPolicyTests.swift; sourceTree = ""; }; C57B00080000000000000002 /* CustomSidebarPaneDataContextCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CustomSidebarPaneDataContextCache.swift; sourceTree = ""; }; C57B00010000000000000002 /* CustomSidebarPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CustomSidebarPanel.swift; sourceTree = ""; }; @@ -2234,6 +2243,7 @@ A5001671 /* SessionRestoredTerminalCommandStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionRestoredTerminalCommandStore.swift; sourceTree = ""; }; A50016B0A1B2C3D4E5F60718 /* SessionSnapshotDebugBenchmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/SessionSnapshotDebugBenchmark.swift; sourceTree = ""; }; F5320003A1B2C3D4E5F60718 /* SessionTranscriptTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionTranscriptTypes.swift; sourceTree = ""; }; + C6611002C6611002C6611002 /* SessionWorkspaceGroupSnapshotParentChainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionWorkspaceGroupSnapshotParentChainTests.swift; sourceTree = ""; }; F92AB6D1CB714189A8F167E5 /* SetAutoTitleSocketTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetAutoTitleSocketTests.swift; sourceTree = ""; }; D10000000000000000A00021 /* SettingsAccountJSONResetBehaviorUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAccountJSONResetBehaviorUITests.swift; sourceTree = ""; }; D10000000000000000A00013 /* SettingsAppBehaviorUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAppBehaviorUITests.swift; sourceTree = ""; }; @@ -2268,6 +2278,8 @@ D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift; sourceTree = ""; }; D7AB34310000000000000002 /* SidebarBonsplitWorkspaceRowDropModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarBonsplitWorkspaceRowDropModifier.swift; sourceTree = ""; }; EA1F00000000000000000004 /* SidebarDirectoryText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarDirectoryText.swift; sourceTree = ""; }; + C9A57232C9A57232C9A57232 /* SidebarDropIndicatorRowScopeResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarDropIndicatorRowScopeResolver.swift; sourceTree = ""; }; + C9A57242C9A57242C9A57242 /* SidebarDropIndicatorRowScopeResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarDropIndicatorRowScopeResolverTests.swift; sourceTree = ""; }; B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = ""; }; 51D800000000000000000002 /* SidebarIdentifierFormattingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarIdentifierFormattingTests.swift; sourceTree = ""; }; 5732B0115732B0115732B011 /* SidebarLazyContractProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debug/SidebarLazyContractProbe.swift; sourceTree = ""; }; @@ -2297,8 +2309,11 @@ 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 = ""; }; + D7AB34310000000000000006 /* SidebarWorkspaceGroupReparentDropPlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceGroupReparentDropPlannerTests.swift; sourceTree = ""; }; + C9A57222C9A57222C9A57222 /* SidebarWorkspaceRenderChildRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceRenderChildRow.swift; sourceTree = ""; }; C9A57202C9A57202C9A57202 /* SidebarWorkspaceRenderItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceRenderItem.swift; sourceTree = ""; }; C9A57212C9A57212C9A57212 /* SidebarWorkspaceRenderItemID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceRenderItemID.swift; sourceTree = ""; }; + 62EBD266146B478FAE0D4745 /* SidebarWorkspaceRenderItemStaleAnchorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceRenderItemStaleAnchorTests.swift; sourceTree = ""; }; C9A5760EC9A5760EC9A5760E /* SidebarWorkspaceReorderDropOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceReorderDropOverlay.swift; sourceTree = ""; }; C9A57306C9A57306C9A57306 /* SidebarWorkspaceReorderDropOverlayHitTestingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceReorderDropOverlayHitTestingTests.swift; sourceTree = ""; }; C9A57608C9A57608C9A57608 /* SidebarWorkspaceReorderDropOverlayTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceReorderDropOverlayTarget.swift; sourceTree = ""; }; @@ -2558,6 +2573,7 @@ C9A57204C9A57204C9A57204 /* WorkspaceGroupMenuSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceGroupMenuSnapshot.swift; sourceTree = ""; }; C9A57205C9A57205C9A57205 /* WorkspaceGroupMoveToMenuState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceGroupMoveToMenuState.swift; sourceTree = ""; }; C9A57402C9A57402C9A57402 /* WorkspaceGroupMoveToMenuStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceGroupMoveToMenuStateTests.swift; sourceTree = ""; }; + C9A57224C9A57224C9A57224 /* WorkspaceGroupNotificationAggregate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceGroupNotificationAggregate.swift; sourceTree = ""; }; C9A57002C9A57002C9A57002 /* WorkspaceGroupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceGroupTests.swift; sourceTree = ""; }; E3B7A4000000000000000008 /* WorkspaceIndicatorStyle+Display.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceIndicatorStyle+Display.swift"; sourceTree = ""; }; CA52C0180000000000000000 /* WorkspaceLayoutMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceLayoutMode.swift"; sourceTree = ""; }; @@ -2964,6 +2980,8 @@ C9A5710AC9A5710AC9A5710A /* SidebarWorkspaceGroupingMetrics.swift */, D5037010000000000000002 /* RenderableSystemSymbol.swift */, C9A57212C9A57212C9A57212 /* SidebarWorkspaceRenderItemID.swift */, + C9A57232C9A57232C9A57232 /* SidebarDropIndicatorRowScopeResolver.swift */, + C9A57222C9A57222C9A57222 /* SidebarWorkspaceRenderChildRow.swift */, C9A57202C9A57202C9A57202 /* SidebarWorkspaceRenderItem.swift */, C9A5760EC9A5760EC9A5760E /* SidebarWorkspaceReorderDropOverlay.swift */, C9A57608C9A57608C9A57608 /* SidebarWorkspaceReorderDropOverlayTarget.swift */, @@ -2977,6 +2995,7 @@ C9A5720CC9A5720CC9A5720C /* TabItemView+WorkspaceGroups.swift */, C9A5720AC9A5720AC9A5720A /* VerticalTabsSidebar+WorkspaceGroups.swift */, C9A57204C9A57204C9A57204 /* WorkspaceGroupMenuSnapshot.swift */, + C9A57224C9A57224C9A57224 /* WorkspaceGroupNotificationAggregate.swift */, C9A57205C9A57205C9A57205 /* WorkspaceGroupMoveToMenuState.swift */, C0DEF0A30000000000000002 /* SidebarPortDisplayText.swift */, 610008EA5E3744DDBE05C8E2 /* InternalTabDragConfiguration.swift */, @@ -3837,6 +3856,8 @@ EF7347934AB1BD387686EE43 /* WorkspaceActionDispatcherTests.swift */, E241BE1D74A7459C8D9C16BC /* WorkspaceTitleProvenanceTests.swift */, F92AB6D1CB714189A8F167E5 /* SetAutoTitleSocketTests.swift */, + BC5355A1BF10411A88950AEC /* ControlWorkspaceGroupCreateParentInferenceTests.swift */, + C6611002C6611002C6611002 /* SessionWorkspaceGroupSnapshotParentChainTests.swift */, 7752D68A758642EFA282C208 /* AutoNamingEngineTests.swift */, 70034F4D644C4896B8B925F3 /* AutoNamingCodexAdapterTests.swift */, 07EC52007B2144C59C047774 /* AutoNamingGrokAdapterTests.swift */, @@ -3845,6 +3866,7 @@ F016B5C09357B3226FA2E014 /* SidebarWorkspaceSnapshotRefreshPolicyTests.swift */, C9A57514C9A57514C9A57514 /* SidebarScrollViewConfiguratorTests.swift */, C9A57506C9A57506C9A57506 /* SidebarWorkspaceScrollLayoutTests.swift */, + 62EBD266146B478FAE0D4745 /* SidebarWorkspaceRenderItemStaleAnchorTests.swift */, D41A7C02D41A7C02D41A7C02 /* SidebarLazyLayoutScaleTests.swift */, C4A570030000000000000002 /* CanvasShortcutContextTests.swift */, C0DEF0B30000000000000002 /* KeyboardShortcutSettingsFileStoreMigrationTests.swift */, @@ -3971,6 +3993,8 @@ C7A50C000000000000000001 /* CmuxTopProcessCPUTests.swift */, C7A50C000000000000000004 /* CmuxTopProcessArgumentsTests.swift */, D7AB34300000000000000006 /* SidebarWorkspaceDropPlannerTests.swift */, + D7AB34310000000000000006 /* SidebarWorkspaceGroupReparentDropPlannerTests.swift */, + C9A57242C9A57242C9A57242 /* SidebarDropIndicatorRowScopeResolverTests.swift */, D7AB34300000000000000106 /* SidebarTabDropIndicatorPredicateTests.swift */, A47E00010000000000000002 /* AuthEnvironmentTests.swift */, C3677001000000000000002 /* CmuxSSHURLRequestTests.swift */, @@ -5001,6 +5025,7 @@ D7AB34300000000000000003 /* SidebarBonsplitTabWorkspaceDropOverlay.swift in Sources */, D7AB34310000000000000001 /* SidebarBonsplitWorkspaceRowDropModifier.swift in Sources */, EA1F00000000000000000003 /* SidebarDirectoryText.swift in Sources */, + C9A57231C9A57231C9A57231 /* SidebarDropIndicatorRowScopeResolver.swift in Sources */, 5732A0115732A0115732A011 /* SidebarLazyContractProbe.swift in Sources */, 5732A0125732A0125732A012 /* SidebarLazyContractProbeKey.swift in Sources */, C0DE5F210000000000000001 /* SidebarMetadataMarkdownRenderer.swift in Sources */, @@ -5016,6 +5041,7 @@ C9A57301C9A57301C9A57301 /* SidebarWorkspaceGroupHeaderMetrics.swift in Sources */, C9A57101C9A57101C9A57101 /* SidebarWorkspaceGroupHeaderView.swift in Sources */, C9A57109C9A57109C9A57109 /* SidebarWorkspaceGroupingMetrics.swift in Sources */, + C9A57221C9A57221C9A57221 /* SidebarWorkspaceRenderChildRow.swift in Sources */, C9A57201C9A57201C9A57201 /* SidebarWorkspaceRenderItem.swift in Sources */, C9A57211C9A57211C9A57211 /* SidebarWorkspaceRenderItemID.swift in Sources */, C9A5760DC9A5760DC9A5760D /* SidebarWorkspaceReorderDropOverlay.swift in Sources */, @@ -5207,6 +5233,7 @@ C0DE36230000000000000001 /* WorkspaceFinderDirectoryResolver.swift in Sources */, C9A57203C9A57203C9A57203 /* WorkspaceGroupMenuSnapshot.swift in Sources */, C9A57206C9A57206C9A57206 /* WorkspaceGroupMoveToMenuState.swift in Sources */, + C9A57223C9A57223C9A57223 /* WorkspaceGroupNotificationAggregate.swift in Sources */, E3B7A4000000000000000007 /* WorkspaceIndicatorStyle+Display.swift in Sources */, CA52B0180000000000000000 /* WorkspaceLayoutMode.swift in Sources */, E3B7A4000000000000000005 /* WorkspacePlacement+Resolution.swift in Sources */, @@ -5468,6 +5495,7 @@ A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */, C0DEF4120000000000000001 /* CommandPaletteSettingsToggleTests.swift in Sources */, C1713006C1713006C1713006 /* CommandPaletteShortcutCustomizationTests.swift in Sources */, + D8FBD4E0BD6E4CED9D906CBA /* ControlWorkspaceGroupCreateParentInferenceTests.swift in Sources */, C65930010000000000000002 /* CrashDiagnosticSessionPolicyTests.swift in Sources */, DEBDADADADADADADAD000003 /* DebugDogfoodCredentialResolverTests.swift in Sources */, B0555303B0555303B0555303 /* DetachedFolderPathLookupCacheTests.swift in Sources */, @@ -5595,6 +5623,7 @@ 8A3392FE64E0605D942213D1 /* SessionIndexViewTests.swift in Sources */, F6572002A1B2C3D4E5F60718 /* SessionPersistenceResumeBindingTests.swift in Sources */, F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */, + C6611001C6611001C6611001 /* SessionWorkspaceGroupSnapshotParentChainTests.swift in Sources */, 583A675AA1224E8D82A44883 /* SetAutoTitleSocketTests.swift in Sources */, A50019B2 /* SettingsSearchIndexTests.swift in Sources */, D36090010000000000000003 /* SettingsWindowPresenterTests.swift in Sources */, @@ -5605,6 +5634,7 @@ 6042A0016042A0016042A001 /* ShortcutHintModifierPolicyTests.swift in Sources */, F6001000A1B2C3D4E5F60718 /* ShortcutUnbindingTests.swift in Sources */, C3467AB10000000000000001 /* ShortcutWhenClauseTests.swift in Sources */, + C9A57241C9A57241C9A57241 /* SidebarDropIndicatorRowScopeResolverTests.swift in Sources */, 51D800000000000000000001 /* SidebarIdentifierFormattingTests.swift in Sources */, D41A7C01D41A7C01D41A7C01 /* SidebarLazyLayoutScaleTests.swift in Sources */, 385C6BA7E78DB87460E5D930 /* SidebarMarkdownRendererTests.swift in Sources */, @@ -5616,6 +5646,8 @@ D7AB34300000000000000005 /* SidebarWorkspaceDropPlannerTests.swift in Sources */, C135190000000000000000B1 /* SidebarWorkspaceGroupConfigOpenerTests.swift in Sources */, C9A57303C9A57303C9A57303 /* SidebarWorkspaceGroupHeaderMetricsTests.swift in Sources */, + D7AB34310000000000000005 /* SidebarWorkspaceGroupReparentDropPlannerTests.swift in Sources */, + AEE71AD3C9354EE3B8B9CC9A /* SidebarWorkspaceRenderItemStaleAnchorTests.swift in Sources */, C9A57305C9A57305C9A57305 /* SidebarWorkspaceReorderDropOverlayHitTestingTests.swift in Sources */, C9A57505C9A57505C9A57505 /* SidebarWorkspaceScrollLayoutTests.swift in Sources */, C0DE56010000000000000001 /* SidebarWorkspaceSelectionAnchorPolicyTests.swift in Sources */, diff --git a/cmuxTests/ControlWorkspaceGroupCreateParentInferenceTests.swift b/cmuxTests/ControlWorkspaceGroupCreateParentInferenceTests.swift new file mode 100644 index 000000000000..d81e2db0acbd --- /dev/null +++ b/cmuxTests/ControlWorkspaceGroupCreateParentInferenceTests.swift @@ -0,0 +1,128 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite(.serialized) +struct ControlWorkspaceGroupCreateParentInferenceTests { + private func call(method: String, params: [String: Any]) throws -> [String: Any] { + let request: [String: Any] = ["id": method, "method": method, "params": params] + let requestData = try JSONSerialization.data(withJSONObject: request) + let requestLine = try #require(String(data: requestData, encoding: .utf8)) + let responseData = try #require(TerminalController.shared.handleSocketLine(requestLine).data(using: .utf8)) + return try #require(JSONSerialization.jsonObject(with: responseData) as? [String: Any]) + } + + @Test func socketCreateGroupInfersParentFromExplicitChildrenInsideOneGroup() throws { + let previousManager = TerminalController.shared.activeTabManagerForCallerNotification() + let manager = TabManager(autoWelcomeIfNeeded: false) + manager.addWorkspace(autoWelcomeIfNeeded: false) + manager.addWorkspace(autoWelcomeIfNeeded: false) + let workspaceIds = manager.tabs.map(\.id) + let parentId = try #require(manager.createWorkspaceGroup( + name: "Parent", + childWorkspaceIds: [ + workspaceIds[0], + workspaceIds[1], + ], + selectAnchor: false, + collapseSidebarSelection: false + )) + + TerminalController.shared.setActiveTabManager(manager) + defer { TerminalController.shared.setActiveTabManager(previousManager) } + + let envelope = try call(method: "workspace.group.create", params: [ + "name": "Sub", + "child_workspace_ids": [ + workspaceIds[1].uuidString, + ], + ]) + + #expect(envelope["ok"] as? Bool == true) + let result = try #require(envelope["result"] as? [String: Any]) + let group = try #require(result["group"] as? [String: Any]) + #expect(group["parent_group_id"] as? String == parentId.uuidString) + let createdGroupIdString = try #require(group["id"] as? String) + let createdGroupId = try #require(UUID(uuidString: createdGroupIdString)) + #expect(manager.workspaceGroups.first { $0.id == createdGroupId }?.parentGroupId == parentId) + } + + @Test func commonWorkspaceGroupIdIgnoresStaleMembership() throws { + let manager = TabManager(autoWelcomeIfNeeded: false) + manager.addWorkspace(autoWelcomeIfNeeded: false) + manager.addWorkspace(autoWelcomeIfNeeded: false) + let workspaceIds = manager.tabs.map(\.id) + let staleGroupId = UUID() + manager.tabs[0].groupId = staleGroupId + manager.tabs[1].groupId = staleGroupId + + let inferredParentId = manager.commonWorkspaceGroupId(for: workspaceIds) + let createdGroupId = manager.createWorkspaceGroup( + name: "Recovered", + childWorkspaceIds: workspaceIds, + parentGroupId: inferredParentId, + selectAnchor: false, + collapseSidebarSelection: false + ) + + #expect(inferredParentId == nil) + let groupId = try #require(createdGroupId) + #expect(manager.workspaceGroups.first { $0.id == groupId }?.parentGroupId == nil) + } + + @Test func socketMoveGroupRejectsNonSiblingRelativeTargets() throws { + let previousManager = TerminalController.shared.activeTabManagerForCallerNotification() + let manager = TabManager(autoWelcomeIfNeeded: false) + for _ in 0..<4 { + manager.addWorkspace(autoWelcomeIfNeeded: false) + } + let workspaceIds = manager.tabs.map(\.id) + let parentId = try #require(manager.createWorkspaceGroup( + name: "Parent", + childWorkspaceIds: [workspaceIds[0]], + selectAnchor: false, + collapseSidebarSelection: false + )) + let childId = try #require(manager.createWorkspaceGroup( + name: "Child", + childWorkspaceIds: [workspaceIds[1]], + parentGroupId: parentId, + selectAnchor: false, + collapseSidebarSelection: false + )) + let rootPeerId = try #require(manager.createWorkspaceGroup( + name: "Root", + childWorkspaceIds: [workspaceIds[2]], + selectAnchor: false, + collapseSidebarSelection: false + )) + let originalOrder = manager.workspaceGroups.map(\.id) + + TerminalController.shared.setActiveTabManager(manager) + defer { TerminalController.shared.setActiveTabManager(previousManager) } + + let beforeEnvelope = try call(method: "workspace.group.move", params: [ + "group_id": childId.uuidString, + "before_group_id": rootPeerId.uuidString, + ]) + #expect(beforeEnvelope["ok"] as? Bool == false) + #expect((beforeEnvelope["error"] as? [String: Any])?["code"] as? String == "invalid_params") + #expect(manager.workspaceGroups.map(\.id) == originalOrder) + #expect(manager.workspaceGroups.first { $0.id == childId }?.parentGroupId == parentId) + + let afterEnvelope = try call(method: "workspace.group.move", params: [ + "group_id": childId.uuidString, + "after_group_id": rootPeerId.uuidString, + ]) + #expect(afterEnvelope["ok"] as? Bool == false) + #expect((afterEnvelope["error"] as? [String: Any])?["code"] as? String == "invalid_params") + #expect(manager.workspaceGroups.map(\.id) == originalOrder) + #expect(manager.workspaceGroups.first { $0.id == childId }?.parentGroupId == parentId) + } +} diff --git a/cmuxTests/MobileHostAuthorizationTests.swift b/cmuxTests/MobileHostAuthorizationTests.swift index b187e8021817..11a2b621d89d 100644 --- a/cmuxTests/MobileHostAuthorizationTests.swift +++ b/cmuxTests/MobileHostAuthorizationTests.swift @@ -950,22 +950,22 @@ struct MobileHostAuthorizationTests { workingDirectory: "/tmp/mobile-a", portOrdinal: 0 ) - let initial = MobileWorkspaceListObserver.summaryHashForTesting( - tabs: [workspace], + let initial = MobileWorkspaceListObserver.summaryHash( + for: [workspace], selectedTabID: workspace.id ) workspace.currentDirectory = "/tmp/mobile-b" - let afterWorkspaceDirectory = MobileWorkspaceListObserver.summaryHashForTesting( - tabs: [workspace], + let afterWorkspaceDirectory = MobileWorkspaceListObserver.summaryHash( + for: [workspace], selectedTabID: workspace.id ) #expect(initial != afterWorkspaceDirectory) workspace.panelDirectories[UUID()] = "/tmp/mobile-terminal" - let afterTerminalDirectory = MobileWorkspaceListObserver.summaryHashForTesting( - tabs: [workspace], + let afterTerminalDirectory = MobileWorkspaceListObserver.summaryHash( + for: [workspace], selectedTabID: workspace.id ) diff --git a/cmuxTests/MobileWorkspaceListFidelityTests.swift b/cmuxTests/MobileWorkspaceListFidelityTests.swift index ad4a5b033e93..33bba6e98ed9 100644 --- a/cmuxTests/MobileWorkspaceListFidelityTests.swift +++ b/cmuxTests/MobileWorkspaceListFidelityTests.swift @@ -71,8 +71,8 @@ struct MobileWorkspaceListFidelityTests { #expect(ordered.count == 3) let versionBefore = workspace.paneLayoutVersion - let before = MobileWorkspaceListObserver.summaryHashForTesting( - tabs: [workspace], + let before = MobileWorkspaceListObserver.summaryHash( + for: [workspace], selectedTabID: workspace.id ) @@ -92,8 +92,8 @@ struct MobileWorkspaceListFidelityTests { "a pure reorder must bump paneLayoutVersion so the observer re-evaluates" ) - let after = MobileWorkspaceListObserver.summaryHashForTesting( - tabs: [workspace], + let after = MobileWorkspaceListObserver.summaryHash( + for: [workspace], selectedTabID: workspace.id ) #expect(before != after, "a pure reorder must change the mobile summary hash") @@ -103,8 +103,8 @@ struct MobileWorkspaceListFidelityTests { let (workspace, ordered) = try makeWorkspaceWithTabTerminals(count: 2) let panelId = try #require(ordered.first) - let before = MobileWorkspaceListObserver.summaryHashForTesting( - tabs: [workspace], + let before = MobileWorkspaceListObserver.summaryHash( + for: [workspace], selectedTabID: workspace.id ) @@ -114,8 +114,8 @@ struct MobileWorkspaceListFidelityTests { workspace.setPanelCustomTitle(panelId: panelId, title: "Renamed Terminal") #expect(workspace.panelTitle(panelId: panelId) == "Renamed Terminal") - let after = MobileWorkspaceListObserver.summaryHashForTesting( - tabs: [workspace], + let after = MobileWorkspaceListObserver.summaryHash( + for: [workspace], selectedTabID: workspace.id ) #expect(before != after, "a terminal rename must change the mobile summary hash") @@ -124,8 +124,8 @@ struct MobileWorkspaceListFidelityTests { @Test func renamingWorkspaceChangesObserverHashAndDisplayedTitle() throws { let (workspace, _) = try makeWorkspaceWithTabTerminals(count: 1) - let before = MobileWorkspaceListObserver.summaryHashForTesting( - tabs: [workspace], + let before = MobileWorkspaceListObserver.summaryHash( + for: [workspace], selectedTabID: workspace.id ) @@ -133,8 +133,8 @@ struct MobileWorkspaceListFidelityTests { // The mobile workspace.list response sends workspace.title. #expect(workspace.title == "Renamed Workspace") - let after = MobileWorkspaceListObserver.summaryHashForTesting( - tabs: [workspace], + let after = MobileWorkspaceListObserver.summaryHash( + for: [workspace], selectedTabID: workspace.id ) #expect(before != after, "a workspace rename must change the mobile summary hash") @@ -153,8 +153,8 @@ struct MobileWorkspaceListFidelityTests { let groupId = try #require(manager.createWorkspaceGroup(name: "Group A")) #expect(member.groupId == nil) - let before = MobileWorkspaceListObserver.summaryHashForTesting( - tabs: manager.tabs, + let before = MobileWorkspaceListObserver.summaryHash( + for: manager.tabs, groups: manager.workspaceGroups, selectedTabID: manager.selectedTabId ) @@ -162,14 +162,35 @@ struct MobileWorkspaceListFidelityTests { // Move the workspace into the group: only `groupId` changes. member.groupId = groupId - let after = MobileWorkspaceListObserver.summaryHashForTesting( - tabs: manager.tabs, + let after = MobileWorkspaceListObserver.summaryHash( + for: manager.tabs, groups: manager.workspaceGroups, selectedTabID: manager.selectedTabId ) #expect(before != after, "a pure group-membership move must change the mobile summary hash") } + @Test func reparentingWorkspaceGroupChangesObserverHash() throws { + let manager = TabManager() + let parentId = try #require(manager.createWorkspaceGroup(name: "Hotels")) + let childId = try #require(manager.createWorkspaceGroup(name: "Marriott")) + + let before = MobileWorkspaceListObserver.summaryHash( + for: manager.tabs, + groups: manager.workspaceGroups, + selectedTabID: manager.selectedTabId + ) + + #expect(manager.setWorkspaceGroupParent(groupId: childId, parentGroupId: parentId)) + + let after = MobileWorkspaceListObserver.summaryHash( + for: manager.tabs, + groups: manager.workspaceGroups, + selectedTabID: manager.selectedTabId + ) + #expect(before != after, "a folder reparent must change the mobile summary hash") + } + /// A new notification (or clearing the latest one) changes only a workspace's /// preview signature, not the tab set, groups, panels, title, or pin state. /// The signature must be folded into the summary hash so the observer @@ -178,22 +199,22 @@ struct MobileWorkspaceListFidelityTests { let manager = TabManager() let workspace = try #require(manager.selectedWorkspace) - let before = MobileWorkspaceListObserver.summaryHashForTesting( - tabs: manager.tabs, + let before = MobileWorkspaceListObserver.summaryHash( + for: manager.tabs, groups: manager.workspaceGroups, selectedTabID: manager.selectedTabId, previewSignatures: [:] ) - let after = MobileWorkspaceListObserver.summaryHashForTesting( - tabs: manager.tabs, + let after = MobileWorkspaceListObserver.summaryHash( + for: manager.tabs, groups: manager.workspaceGroups, selectedTabID: manager.selectedTabId, previewSignatures: [workspace.id: 42] ) #expect(before != after, "a preview-signature change must change the mobile summary hash") - let changed = MobileWorkspaceListObserver.summaryHashForTesting( - tabs: manager.tabs, + let changed = MobileWorkspaceListObserver.summaryHash( + for: manager.tabs, groups: manager.workspaceGroups, selectedTabID: manager.selectedTabId, previewSignatures: [workspace.id: 43] diff --git a/cmuxTests/SessionWorkspaceGroupSnapshotParentChainTests.swift b/cmuxTests/SessionWorkspaceGroupSnapshotParentChainTests.swift new file mode 100644 index 000000000000..63cdbb3c0c98 --- /dev/null +++ b/cmuxTests/SessionWorkspaceGroupSnapshotParentChainTests.swift @@ -0,0 +1,44 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite("Session workspace group parent chains") +struct SessionWorkspaceGroupSnapshotParentChainTests { + @Test func snapshotWriterLinksChildFolderToNearestRestorableAncestor() throws { + let manager = TabManager(autoWelcomeIfNeeded: false) + manager.addWorkspace(autoWelcomeIfNeeded: false) + manager.addWorkspace(autoWelcomeIfNeeded: false) + manager.addWorkspace(autoWelcomeIfNeeded: false) + let workspaceIds = manager.tabs.map(\.id) + let grandparentId = try #require(manager.createWorkspaceGroup( + name: "Hotels", + childWorkspaceIds: [workspaceIds[0]] + )) + let parentId = try #require(manager.createWorkspaceGroup( + name: "Marriott", + childWorkspaceIds: [workspaceIds[1]], + parentGroupId: grandparentId + )) + let childId = try #require(manager.createWorkspaceGroup( + name: "Downtown", + childWorkspaceIds: [workspaceIds[2]], + parentGroupId: parentId + )) + manager.tabs.first { $0.id == workspaceIds[1] }?.isRemoteTmuxMirror = true + + let snapshot = manager.sessionSnapshot(includeScrollback: false) + let groups = try #require(snapshot.workspaceGroups) + let child = try #require(groups.first { $0.id == childId }) + + #expect(groups.contains { $0.id == grandparentId }) + #expect(!groups.contains { $0.id == parentId }) + #expect(child.parentGroupId == grandparentId) + #expect(child.parentGroupIndex == groups.firstIndex { $0.id == grandparentId }) + } +} diff --git a/cmuxTests/SidebarDropIndicatorRowScopeResolverTests.swift b/cmuxTests/SidebarDropIndicatorRowScopeResolverTests.swift new file mode 100644 index 000000000000..91b275ac4448 --- /dev/null +++ b/cmuxTests/SidebarDropIndicatorRowScopeResolverTests.swift @@ -0,0 +1,100 @@ +import Foundation +import Testing + +import CmuxFoundation + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite struct SidebarDropIndicatorRowScopeResolverTests { + @Test func parentGroupRowsIncludeExpandedChildFolderDescendants() throws { + let scenario = try makeNestedGroupManager() + let parent = try #require(scenario.manager.workspaceGroups.first { $0.id == scenario.parentId }) + let child = try #require(scenario.manager.workspaceGroups.first { $0.id == scenario.childId }) + let rowIds = rowIds(manager: scenario.manager, scope: .group(parent.id)) + let childAnchorIndex = try #require(rowIds.firstIndex(of: child.anchorWorkspaceId)) + let childMemberIndex = try #require(rowIds.firstIndex(of: scenario.originalIds[2])) + let indicator = SidebarDropIndicator(tabId: child.anchorWorkspaceId, edge: .bottom) + let predicate = SidebarTabDropIndicatorPredicate() + + #expect(childAnchorIndex < childMemberIndex) + #expect(rowIds.last != child.anchorWorkspaceId) + #expect(!predicate.bottomVisible( + forTabId: child.anchorWorkspaceId, + draggedTabId: scenario.originalIds[0], + dropIndicator: indicator, + tabIds: rowIds, + indicatorScope: .group(parent.id) + )) + #expect(predicate.topVisible( + forTabId: scenario.originalIds[2], + draggedTabId: scenario.originalIds[0], + dropIndicator: indicator, + tabIds: rowIds + )) + } + + @Test func parentGroupRowsKeepCollapsedChildFolderAsFinalVisibleRow() throws { + let scenario = try makeNestedGroupManager() + let parent = try #require(scenario.manager.workspaceGroups.first { $0.id == scenario.parentId }) + let child = try #require(scenario.manager.workspaceGroups.first { $0.id == scenario.childId }) + scenario.manager.toggleWorkspaceGroupCollapsed(groupId: child.id) + let rowIds = rowIds(manager: scenario.manager, scope: .group(parent.id)) + let indicator = SidebarDropIndicator(tabId: child.anchorWorkspaceId, edge: .bottom) + + #expect(rowIds.contains(child.anchorWorkspaceId)) + #expect(!rowIds.contains(scenario.originalIds[2])) + #expect(rowIds.last == child.anchorWorkspaceId) + #expect(SidebarTabDropIndicatorPredicate().bottomVisible( + forTabId: child.anchorWorkspaceId, + draggedTabId: scenario.originalIds[0], + dropIndicator: indicator, + tabIds: rowIds, + indicatorScope: .group(parent.id) + )) + } + + private func makeNestedGroupManager() throws -> ( + manager: TabManager, + originalIds: [UUID], + parentId: UUID, + childId: UUID + ) { + let manager = TabManager() + manager.addWorkspace(autoWelcomeIfNeeded: false) + manager.addWorkspace(autoWelcomeIfNeeded: false) + manager.addWorkspace(autoWelcomeIfNeeded: false) + manager.addWorkspace(autoWelcomeIfNeeded: false) + let originalIds = manager.tabs.map(\.id) + let parentId = try #require(manager.createWorkspaceGroup(name: "Hotels", childWorkspaceIds: [ + originalIds[1], + ])) + let childId = try #require(manager.createWorkspaceGroup( + name: "Marriott", + childWorkspaceIds: [ + originalIds[2], + ], + parentGroupId: parentId + )) + return (manager, originalIds, parentId, childId) + } + + private func rowIds( + manager: TabManager, + scope: SidebarWorkspaceReorderDropIndicatorScope + ) -> [UUID] { + SidebarDropIndicatorRowScopeResolver( + tabs: manager.tabs, + workspaceGroups: manager.workspaceGroups, + workspaceRenderItems: SidebarWorkspaceRenderItem.renderItems( + tabs: manager.tabs, + groupsById: Dictionary(uniqueKeysWithValues: manager.workspaceGroups.map { ($0.id, $0) }) + ), + topLevelWorkspaceRowIds: [] + ).rowIds(for: scope) + } +} diff --git a/cmuxTests/SidebarWorkspaceGroupReparentDropPlannerTests.swift b/cmuxTests/SidebarWorkspaceGroupReparentDropPlannerTests.swift new file mode 100644 index 000000000000..897e8be1dead --- /dev/null +++ b/cmuxTests/SidebarWorkspaceGroupReparentDropPlannerTests.swift @@ -0,0 +1,95 @@ +import CoreGraphics +import Foundation +import Testing + +import CmuxFoundation + +private func require(_ value: T?, _ message: String? = nil) throws -> T { + _ = message + return try #require(value) +} + +private func expectEqual(_ lhs: T, _ rhs: T, _ message: String? = nil) { + _ = message + #expect(lhs == rhs) +} + +@Suite struct SidebarWorkspaceGroupReparentDropPlannerTests { + @Test func groupHeaderCenterDropReparentsDraggedFolder() throws { + let fixture = GroupReparentFixture() + + let plan = try require(SidebarWorkspaceReorderDropResolver().plan( + for: fixture.request(point: CGPoint(x: 12, y: 56), draggedWorkspaceId: fixture.childAnchor) + )) + + #expect(plan.indicator == nil) + expectEqual(plan.indicatorScope, SidebarWorkspaceReorderDropIndicatorScope.group(fixture.parentGroupId)) + guard case .reparentGroup(let groupId, let parentGroupId) = plan.action else { + Issue.record("Expected group reparent plan") + return + } + expectEqual(groupId, fixture.childGroupId) + expectEqual(parentGroupId, fixture.parentGroupId) + } + + @Test func groupHeaderCenterDropRejectsFolderReparentCycle() { + let fixture = GroupReparentFixture(childIsNestedUnderParent: true) + + let plan = SidebarWorkspaceReorderDropResolver().plan( + for: fixture.request( + point: CGPoint(x: 12, y: 96), + draggedWorkspaceId: fixture.parentAnchor + ) + ) + + if case .reparentGroup = plan?.action { + Issue.record("Cycle candidate should not produce a group reparent plan") + } + } +} + +private struct GroupReparentFixture { + let parentAnchor = UUID() + let childAnchor = UUID() + let parentGroupId = UUID() + let childGroupId = UUID() + let childIsNestedUnderParent: Bool + + init(childIsNestedUnderParent: Bool = false) { + self.childIsNestedUnderParent = childIsNestedUnderParent + } + + func request(point: CGPoint, draggedWorkspaceId: UUID) -> SidebarWorkspaceReorderDropRequest { + SidebarWorkspaceReorderDropRequest( + point: point, + draggedWorkspaceId: draggedWorkspaceId, + workspaces: [ + SidebarWorkspaceReorderWorkspaceSnapshot(id: parentAnchor, isPinned: false, groupId: parentGroupId), + SidebarWorkspaceReorderWorkspaceSnapshot(id: childAnchor, isPinned: false, groupId: childGroupId) + ], + groups: [ + SidebarWorkspaceReorderGroupSnapshot(id: parentGroupId, anchorWorkspaceId: parentAnchor, isPinned: false), + SidebarWorkspaceReorderGroupSnapshot( + id: childGroupId, + anchorWorkspaceId: childAnchor, + isPinned: false, + parentGroupId: childIsNestedUnderParent ? parentGroupId : nil + ) + ], + targets: [ + SidebarWorkspaceReorderDropTarget( + workspaceId: parentAnchor, + groupId: parentGroupId, + isGroupHeader: true, + frame: CGRect(x: 0, y: 40, width: 180, height: 32) + ), + SidebarWorkspaceReorderDropTarget( + workspaceId: childAnchor, + groupId: childGroupId, + isGroupHeader: true, + frame: CGRect(x: 12, y: 80, width: 168, height: 32) + ) + ] + ) + } +} diff --git a/cmuxTests/SidebarWorkspaceRenderItemStaleAnchorTests.swift b/cmuxTests/SidebarWorkspaceRenderItemStaleAnchorTests.swift new file mode 100644 index 000000000000..283b15e452d1 --- /dev/null +++ b/cmuxTests/SidebarWorkspaceRenderItemStaleAnchorTests.swift @@ -0,0 +1,84 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite("Sidebar workspace render stale anchors") +struct SidebarWorkspaceRenderItemStaleAnchorTests { + private func makeTabManager(workspaceCount: Int = 3) -> TabManager { + let manager = TabManager() + for _ in 0.. [SidebarWorkspaceRenderItem] { + SidebarWorkspaceRenderItem.renderItems( + tabs: manager.tabs, + groupsById: Dictionary(uniqueKeysWithValues: manager.workspaceGroups.map { ($0.id, $0) }) + ) + } + + @Test func renderItemsPromoteMembersWhenGroupAnchorIsMissing() throws { + let manager = makeTabManager() + let originalIds = manager.tabs.map(\.id) + let groupId = try #require(manager.createWorkspaceGroup(name: "Stale", childWorkspaceIds: [ + originalIds[1], + originalIds[2], + ])) + let group = try #require(manager.workspaceGroups.first { $0.id == groupId }) + + manager.tabs.removeAll { $0.id == group.anchorWorkspaceId } + let items = renderItems(from: manager) + let renderedGroupIds = items.compactMap { item -> UUID? in + guard case .groupHeader(let group, _, _) = item else { return nil } + return group.id + } + let workspaceRows = items.compactMap { item -> (UUID, Int)? in + guard case .workspace(let workspace, let depth) = item else { return nil } + return (workspace.id, depth) + } + + #expect(!renderedGroupIds.contains(groupId)) + #expect(workspaceRows.contains { id, depth in id == originalIds[1] && depth == 0 }) + #expect(workspaceRows.contains { id, depth in id == originalIds[2] && depth == 0 }) + } + + @Test func renderItemsPromoteNestedChildGroupWhenParentAnchorIsMissing() throws { + let manager = makeTabManager() + let originalIds = manager.tabs.map(\.id) + let parentId = try #require(manager.createWorkspaceGroup(name: "Parent", childWorkspaceIds: [ + originalIds[0], + ])) + let childId = try #require(manager.createWorkspaceGroup( + name: "Child", + childWorkspaceIds: [ + originalIds[1], + ], + parentGroupId: parentId + )) + let parent = try #require(manager.workspaceGroups.first { $0.id == parentId }) + + manager.tabs.removeAll { $0.id == parent.anchorWorkspaceId } + let items = renderItems(from: manager) + let groupRows = items.compactMap { item -> (UUID, Int)? in + guard case .groupHeader(let group, _, let depth) = item else { return nil } + return (group.id, depth) + } + let workspaceRows = items.compactMap { item -> (UUID, Int)? in + guard case .workspace(let workspace, let depth) = item else { return nil } + return (workspace.id, depth) + } + + #expect(!groupRows.contains { id, _ in id == parentId }) + #expect(groupRows.contains { id, depth in id == childId && depth == 0 }) + #expect(workspaceRows.contains { id, depth in id == originalIds[0] && depth == 0 }) + #expect(workspaceRows.contains { id, depth in id == originalIds[1] && depth == 1 }) + } +} diff --git a/cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift b/cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift index 8c522a79d9ed..f6e874354640 100644 --- a/cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift +++ b/cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift @@ -260,12 +260,31 @@ import Testing ) == anchorId) } - private func makeGroup(isCollapsed: Bool, anchorWorkspaceId: UUID) -> WorkspaceGroup { + @Test func scrollTargetUsesCollapsedAncestorAnchor() { + let parentId = UUID() + let parentAnchorId = UUID() + let child = makeGroup(isCollapsed: true, anchorWorkspaceId: UUID(), parentGroupId: parentId) + let parent = makeGroup(id: parentId, isCollapsed: true, anchorWorkspaceId: parentAnchorId) + + #expect(SidebarSelectedWorkspaceScrollPolicy.scrollTargetWorkspaceId( + selectedWorkspaceId: UUID(), + group: child, + groupsById: [parent.id: parent, child.id: child] + ) == parentAnchorId) + } + + private func makeGroup( + id: UUID = UUID(), + isCollapsed: Bool, + anchorWorkspaceId: UUID, + parentGroupId: UUID? = nil + ) -> WorkspaceGroup { WorkspaceGroup( - id: UUID(), + id: id, name: "group", isCollapsed: isCollapsed, isPinned: false, + parentGroupId: parentGroupId, anchorWorkspaceId: anchorWorkspaceId, customColor: nil, iconSymbol: nil diff --git a/cmuxTests/WorkspaceGroupTests.swift b/cmuxTests/WorkspaceGroupTests.swift index 73fe710913d0..584a7e3aebb3 100644 --- a/cmuxTests/WorkspaceGroupTests.swift +++ b/cmuxTests/WorkspaceGroupTests.swift @@ -174,7 +174,7 @@ struct WorkspaceGroupTests { #expect(manager.tabs.map(\.id) == orderBefore) } - @Test func collapsedGroupRenderItemCarriesMembersWithoutRenderingChildren() throws { + @Test func collapsedGroupRenderItemCarriesMemberCountWithoutRenderingChildren() throws { let manager = makeTabManager() manager.addWorkspace(autoWelcomeIfNeeded: false) manager.addWorkspace(autoWelcomeIfNeeded: false) @@ -191,26 +191,22 @@ struct WorkspaceGroupTests { groupsById: Dictionary(uniqueKeysWithValues: manager.workspaceGroups.map { ($0.id, $0) }) ) - var groupMemberIds: [UUID] = [] + var groupMemberCount = 0 var visibleWorkspaceIds: [UUID] = [] var visibleRowIds: [UUID] = [] for item in items { visibleRowIds.append(item.rowWorkspaceId) switch item { - case .groupHeader(let renderedGroup, let memberWorkspaceIds) where renderedGroup.id == groupId: - groupMemberIds = memberWorkspaceIds + case .groupHeader(let renderedGroup, let memberCount, _) where renderedGroup.id == groupId: + groupMemberCount = memberCount case .groupHeader: break - case .workspace(let workspace): + case .workspace(let workspace, _): visibleWorkspaceIds.append(workspace.id) } } - #expect(groupMemberIds == [ - group.anchorWorkspaceId, - originalIds[1], - originalIds[2], - ]) + #expect(groupMemberCount == 3) #expect(!visibleWorkspaceIds.contains(originalIds[1])) #expect(!visibleWorkspaceIds.contains(originalIds[2])) #expect(visibleRowIds == [ @@ -220,6 +216,165 @@ struct WorkspaceGroupTests { ]) } + @Test func renderItemsShowWorkspaceWithStaleGroupIdAsRootRow() { + let manager = makeTabManager() + let staleGroupId = UUID() + let staleWorkspaceId = manager.tabs[0].id + manager.tabs[0].groupId = staleGroupId + + let items = SidebarWorkspaceRenderItem.renderItems(tabs: manager.tabs, groupsById: [:]) + let visibleWorkspaceRows = items.compactMap { item -> (UUID, Int)? in + guard case .workspace(let workspace, let depth) = item else { return nil } + return (workspace.id, depth) + } + + #expect(visibleWorkspaceRows.contains { id, depth in id == staleWorkspaceId && depth == 0 }) + #expect(items.first?.rowWorkspaceId == staleWorkspaceId) + } + + @Test func nestedGroupRenderItemsRecurseWithDepthAndMixedLooseWorkspaces() throws { + let manager = makeTabManager() + manager.addWorkspace(autoWelcomeIfNeeded: false) + manager.addWorkspace(autoWelcomeIfNeeded: false) + let originalIds = manager.tabs.map(\.id) + + let hotelsId = try #require(manager.createWorkspaceGroup(name: "Hotels", childWorkspaceIds: [ + originalIds[1], + ])) + let marriottId = try #require(manager.createWorkspaceGroup( + name: "Marriott", + childWorkspaceIds: [ + originalIds[2], + ], + parentGroupId: hotelsId + )) + let hotels = try #require(manager.workspaceGroups.first { $0.id == hotelsId }) + let marriott = try #require(manager.workspaceGroups.first { $0.id == marriottId }) + let items = SidebarWorkspaceRenderItem.renderItems( + tabs: manager.tabs, + groupsById: Dictionary(uniqueKeysWithValues: manager.workspaceGroups.map { ($0.id, $0) }) + ) + + let projected = items.map { item -> (SidebarWorkspaceRenderItemID, UUID, Int) in + (item.id, item.rowWorkspaceId, item.depth) + } + + #expect(projected.contains(where: { $0 == (.group(hotelsId), hotels.anchorWorkspaceId, 0) })) + #expect(projected.contains(where: { $0 == (.workspace(originalIds[1]), originalIds[1], 1) })) + #expect(projected.contains(where: { $0 == (.group(marriottId), marriott.anchorWorkspaceId, 1) })) + #expect(projected.contains(where: { $0 == (.workspace(originalIds[2]), originalIds[2], 2) })) + } + + @Test func collapsedParentGroupRenderItemHidesDescendantFolderRows() throws { + let manager = makeTabManager() + manager.addWorkspace(autoWelcomeIfNeeded: false) + let originalIds = manager.tabs.map(\.id) + + let hotelsId = try #require(manager.createWorkspaceGroup(name: "Hotels", childWorkspaceIds: [ + originalIds[1], + ])) + let marriottId = try #require(manager.createWorkspaceGroup( + name: "Marriott", + childWorkspaceIds: [ + originalIds[2], + ], + parentGroupId: hotelsId + )) + manager.toggleWorkspaceGroupCollapsed(groupId: hotelsId) + let items = SidebarWorkspaceRenderItem.renderItems( + tabs: manager.tabs, + groupsById: Dictionary(uniqueKeysWithValues: manager.workspaceGroups.map { ($0.id, $0) }) + ) + let groupIds = items.compactMap { item -> UUID? in + guard case .groupHeader(let group, _, _) = item else { return nil } + return group.id + } + let visibleWorkspaceIds = items.compactMap { item -> UUID? in + guard case .workspace(let workspace, _) = item else { return nil } + return workspace.id + } + + #expect(groupIds.contains(hotelsId)) + #expect(!groupIds.contains(marriottId)) + #expect(!visibleWorkspaceIds.contains(originalIds[1])) + #expect(!visibleWorkspaceIds.contains(originalIds[2])) + } + + @Test func nestedGroupHeaderUsesRootTopLevelReorderSpace() throws { + let manager = makeTabManager() + manager.addWorkspace(autoWelcomeIfNeeded: false) + let originalIds = manager.tabs.map(\.id) + + let parentId = try #require(manager.createWorkspaceGroup(name: "Hotels", childWorkspaceIds: [originalIds[0]])) + let childId = try #require(manager.createWorkspaceGroup( + name: "Marriott", + childWorkspaceIds: [originalIds[1]], + parentGroupId: parentId + )) + let parentAnchorId = try #require(manager.workspaceGroups.first { $0.id == parentId }?.anchorWorkspaceId) + let childAnchorId = try #require(manager.workspaceGroups.first { $0.id == childId }?.anchorWorkspaceId) + + #expect(manager.sidebarReorderUsesTopLevelRows( + forDraggedWorkspaceId: parentAnchorId, + targetWorkspaceId: nil + )) + #expect(manager.sidebarReorderUsesTopLevelRows( + forDraggedWorkspaceId: childAnchorId, + targetWorkspaceId: nil + )) + } + + @Test func droppingNestedGroupToRootEndPromotesWhenPlannedIndexIsUnchanged() throws { + let manager = makeTabManager() + let originalIds = manager.tabs.map(\.id) + + let parentId = try #require(manager.createWorkspaceGroup(name: "Hotels", childWorkspaceIds: [originalIds[0]])) + let childId = try #require(manager.createWorkspaceGroup( + name: "Marriott", + childWorkspaceIds: [originalIds[1]], + parentGroupId: parentId + )) + let childAnchorId = try #require(manager.workspaceGroups.first { $0.id == childId }?.anchorWorkspaceId) + let usesTopLevelRows = manager.sidebarReorderUsesTopLevelRows( + forDraggedWorkspaceId: childAnchorId, + targetWorkspaceId: nil + ) + let reorderIds = manager.sidebarReorderWorkspaceIds( + forDraggedWorkspaceId: childAnchorId, + targetWorkspaceId: nil, + usesTopLevelRows: usesTopLevelRows + ) + let pinnedIds = manager.sidebarReorderPinnedWorkspaceIds( + forDraggedWorkspaceId: childAnchorId, + targetWorkspaceId: nil, + usesTopLevelRows: usesTopLevelRows + ) + let legalInsertionRange = manager.sidebarReorderLegalInsertionRange( + forDraggedWorkspaceId: childAnchorId, + targetWorkspaceId: nil, + usesTopLevelRows: usesTopLevelRows + ) + let fromIndex = try #require(reorderIds.firstIndex(of: childAnchorId)) + let targetIndex = try #require(SidebarDropPlanner().targetIndex( + draggedTabId: childAnchorId, + targetTabId: nil, + indicator: SidebarDropIndicator(tabId: nil, edge: .bottom), + tabIds: reorderIds, + pinnedTabIds: pinnedIds, + legalInsertionRange: legalInsertionRange + )) + + #expect(usesTopLevelRows) + #expect(targetIndex == fromIndex) + #expect(manager.reorderSidebarWorkspace( + tabId: childAnchorId, + toIndex: targetIndex, + isDragOperation: true, + usesTopLevelRows: usesTopLevelRows + )) + #expect(manager.workspaceGroups.first { $0.id == childId }?.parentGroupId == nil) + } + @Test func groupHeaderEdgeDropUsesTopLevelIndicatorScope() throws { let manager = makeTabManager() manager.addWorkspace(autoWelcomeIfNeeded: false) @@ -973,6 +1128,110 @@ struct WorkspaceGroupTests { #expect(restoredGroup.iconSymbol == "leaf.fill") } + @Test func sessionSnapshotRoundtripPreservesNestedGroupParent() throws { + let manager = makeTabManager() + manager.addWorkspace(autoWelcomeIfNeeded: false) + let originalIds = manager.tabs.map(\.id) + let parentId = try #require(manager.createWorkspaceGroup(name: "Hotels", childWorkspaceIds: [originalIds[0]])) + let childId = try #require(manager.createWorkspaceGroup( + name: "Marriott", + childWorkspaceIds: [originalIds[1]], + parentGroupId: parentId + )) + + let snapshot = manager.sessionSnapshot(includeScrollback: false) + let groups = try #require(snapshot.workspaceGroups) + let childSnapshot = try #require(groups.first { $0.id == childId }) + #expect(childSnapshot.parentGroupId == parentId) + #expect(childSnapshot.parentGroupIndex == groups.firstIndex { $0.id == parentId }) + + let restored = TabManager() + restored.restoreSessionSnapshot(snapshot) + #expect(restored.workspaceGroups.first { $0.id == childId }?.parentGroupId == parentId) + } + + @Test func sessionSnapshotRestoreUsesParentGroupIndexFallback() throws { + let manager = makeTabManager() + manager.addWorkspace(autoWelcomeIfNeeded: false) + let originalIds = manager.tabs.map(\.id) + let parentId = try #require(manager.createWorkspaceGroup(name: "Hotels", childWorkspaceIds: [originalIds[0]])) + let childId = try #require(manager.createWorkspaceGroup( + name: "Marriott", + childWorkspaceIds: [originalIds[1]], + parentGroupId: parentId + )) + var snapshot = manager.sessionSnapshot(includeScrollback: false) + var groups = try #require(snapshot.workspaceGroups) + let parentIndex = try #require(groups.firstIndex { $0.id == parentId }) + let childIndex = try #require(groups.firstIndex { $0.id == childId }) + groups[childIndex].parentGroupId = nil + groups[childIndex].parentGroupIndex = parentIndex + snapshot.workspaceGroups = groups + + let restored = TabManager() + restored.restoreSessionSnapshot(snapshot) + + #expect(restored.workspaceGroups.first { $0.id == childId }?.parentGroupId == parentId) + } + + @Test func sessionSnapshotRestoreRelinksGrandchildToNearestSurvivingAncestor() throws { + let manager = makeTabManager() + manager.addWorkspace(autoWelcomeIfNeeded: false) + manager.addWorkspace(autoWelcomeIfNeeded: false) + let originalIds = manager.tabs.map(\.id) + let grandparentId = try #require(manager.createWorkspaceGroup( + name: "Hotels", + childWorkspaceIds: [originalIds[0]] + )) + let parentId = try #require(manager.createWorkspaceGroup( + name: "Marriott", + childWorkspaceIds: [originalIds[1]], + parentGroupId: grandparentId + )) + let childId = try #require(manager.createWorkspaceGroup( + name: "Downtown", + childWorkspaceIds: [originalIds[2]], + parentGroupId: parentId + )) + + var snapshot = manager.sessionSnapshot(includeScrollback: false) + // Drop the intermediate folder by removing its only member workspace, so + // it has nothing to restore. The surviving grandchild must re-attach to + // the nearest surviving ancestor (the grandparent) rather than flatten to + // the root. + snapshot.workspaces.removeAll { $0.groupId == parentId } + + let restored = TabManager() + restored.restoreSessionSnapshot(snapshot) + + #expect(restored.workspaceGroups.contains { $0.id == grandparentId }) + #expect(!restored.workspaceGroups.contains { $0.id == parentId }) + #expect(restored.workspaceGroups.first { $0.id == childId }?.parentGroupId == grandparentId) + } + + @Test func legacyWorkspaceGroupSnapshotDecodesAsTopLevelFolder() throws { + let groupId = UUID() + let anchorId = UUID() + let json = """ + { + "id": "\(groupId.uuidString)", + "name": "Legacy", + "isCollapsed": false, + "anchorWorkspaceId": "\(anchorId.uuidString)", + "anchorMemberIndex": 0, + "isPinned": false, + "customColor": null, + "iconSymbol": null + } + """ + + let decoded = try JSONDecoder().decode(SessionWorkspaceGroupSnapshot.self, from: Data(json.utf8)) + + #expect(decoded.id == groupId) + #expect(decoded.parentGroupId == nil) + #expect(decoded.parentGroupIndex == nil) + } + @Test func workspaceGroupIconSymbolResolutionFallsBackToRenderableIcon() { #expect(RenderableSystemSymbol.resolvedWorkspaceGroupIcon(explicit: nil, configured: nil) == "folder.fill") #expect(RenderableSystemSymbol.resolvedWorkspaceGroupIcon(explicit: " ", configured: "leaf.fill") == "leaf.fill")