diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift index 501914442af9..7822c729cc26 100644 --- a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift @@ -317,7 +317,7 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable { ticket: ticket, workspaceSelection: workspaceSelection.value ) - case "workspace.move", "workspace.group.action": + case "workspace.move", "workspace.group.action", "workspace.group.create": // These mutations are Mac-scoped. Always preserve the attach-ticket // context when one exists so the host can reject workspace-scoped // tickets instead of receiving a Stack-only request. diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCClientTests.swift b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCClientTests.swift index 04f406af3a4e..d261ece3b24a 100644 --- a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCClientTests.swift +++ b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCClientTests.swift @@ -274,6 +274,21 @@ import Testing #expect(params["title"] as? String == "Project Alpha") } + @Test func workspaceGroupCreateRequestEncodesTitle() throws { + let data = try MobileCoreRPCClient.requestData( + method: "workspace.group.create", + params: [ + "title": "Ops", + ], + id: "group-create-request" + ) + let request = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let params = try #require(request["params"] as? [String: Any]) + #expect(request["id"] as? String == "group-create-request") + #expect(request["method"] as? String == "workspace.group.create") + #expect(params["title"] as? String == "Ops") + } + @Test func attachTicketInputDecodesAttachURL() throws { let route = try CmxAttachRoute( id: "tailscale", diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCGroupActionAuthTests.swift b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCGroupActionAuthTests.swift index 909a589521ac..d9c73412f642 100644 --- a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCGroupActionAuthTests.swift +++ b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCGroupActionAuthTests.swift @@ -128,4 +128,44 @@ import Testing #expect(frame.stackAccessToken == "test-stack-token") #expect(frame.hasAuth) } + + @Test func workspaceGroupCreateCarriesWorkspaceScopedAttachTicketContext() async throws { + let route = try hostPortRoute(kind: .tailscale, host: "100.64.0.5", port: 58465) + let transport = QueuedCancellationProbeTransport() + let runtime = TestMobileSyncRuntime( + transportFactory: QueuedCancellationProbeTransportFactory(transport: transport), + stackAccessToken: "test-stack-token" + ) + let ticket = try CmxAttachTicket( + workspaceID: "workspace-main", + terminalID: nil, + macDeviceID: "test-mac", + macDisplayName: "Test Mac", + routes: [route], + expiresAt: Date().addingTimeInterval(60), + authToken: "ticket-secret" + ) + let client = MobileCoreRPCClient( + runtime: runtime, + route: route, + ticket: ticket, + allowsStackAuthFallback: true + ) + let request = try MobileCoreRPCClient.requestData( + method: "workspace.group.create", + params: [ + "title": "Ops", + ] + ) + let task = Task { try await client.sendRequest(request) } + let sent = try await transport.waitForSentRequestCount(1) + task.cancel() + _ = try? await task.value + + let frame = try #require(sent.first) + #expect(frame.method == "workspace.group.create") + #expect(frame.attachToken == "ticket-secret") + #expect(frame.stackAccessToken == "test-stack-token") + #expect(frame.hasAuth) + } } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Helpers.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Helpers.swift index 07b677be259c..a889e074c00a 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Helpers.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Helpers.swift @@ -31,7 +31,8 @@ extension MobileShellComposite { supportsReadStateActions: supportedHostCapabilities.contains("workspace.read_state.v1"), supportsCloseActions: supportedHostCapabilities.contains("workspace.close.v1"), supportsMoveActions: supportedHostCapabilities.contains("workspace.move.v1") && allowsMacScopedMutations, - supportsGroupActions: supportedHostCapabilities.contains("workspace.group_actions.v1") && allowsMacScopedMutations + supportsGroupActions: supportedHostCapabilities.contains("workspace.group_actions.v1") && allowsMacScopedMutations, + supportsGroupCreate: supportedHostCapabilities.contains("workspace.group_create.v1") && allowsMacScopedMutations ) } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceActions.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceActions.swift index ffc717786a57..07c815740d7a 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceActions.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceActions.swift @@ -230,6 +230,41 @@ extension MobileShellComposite { await sendWorkspaceGroupMutation(id: id, action: "delete", title: nil, actionName: "delete_group") } + /// Create a workspace group on the foreground Mac. + /// - Parameter title: Optional group title. Whitespace-only titles use the Mac's default auto-name. + /// - Returns: `success` when the Mac accepted the request, otherwise the + /// failure the UI should surface. + @discardableResult + public func createWorkspaceGroup( + title: String? = nil + ) async -> Result { + let target = WorkspaceMutationTarget( + client: remoteClient, + isForeground: true, + macDeviceID: foregroundMacDeviceID + ) + let hostDisplayName = workspaceMutationHostDisplayName(target: target, fallback: nil) + guard supportedHostCapabilities.contains("workspace.group_create.v1") else { + return .failure(.unsupported(hostDisplayName: hostDisplayName)) + } + guard macScopedWorkspaceMutationIsAuthorized(target: target) else { + return .failure(.authorizationFailed(hostDisplayName: hostDisplayName)) + } + var params: [String: Any] = [:] + if let trimmed = title?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty { + params["title"] = trimmed + } + return await sendWorkspaceMutation( + method: "workspace.group.create", + params: params, + target: target, + hostDisplayName: hostDisplayName, + logID: "foreground", + actionName: "create_group" + ) + } + private func workspaceActionCapabilities(for id: MobileWorkspacePreview.ID) -> MobileWorkspaceActionCapabilities { workspaces.first { $0.id == id }?.actionCapabilities ?? .none } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 98f1f2dede21..6d932c0c8387 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -93,11 +93,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { private static let workspaceCloseCapability = "workspace.close.v1" private static let workspaceMoveCapability = "workspace.move.v1" private static let workspaceGroupActionsCapability = "workspace.group_actions.v1" - private static let workspaceCreateInGroupCapability = "workspace.create_in_group.v1" + private static let workspaceCreateInGroupCapability = "workspace.create_in_group.v1", workspaceGroupCreateCapability = "workspace.group_create.v1" private static let dogfoodFeedbackCapability = "dogfood.v1" private static let workspaceGroupsCapability = "workspace.groups.v1" private static let terminalOutputCapabilityTimeoutNanoseconds: UInt64 = 750_000_000 - /// How long the render-grid stream may stay silent (no event of any topic) /// before the liveness watchdog suspects the push subscription is dead and /// runs a bounded host probe; only a failed probe forces the @@ -322,6 +321,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { public var supportsWorkspaceGroupActions: Bool { supportedHostCapabilities.contains(Self.workspaceGroupActionsCapability) && allowsMacScopedWorkspaceMutations } /// Whether the Mac supports creating a workspace directly inside a group. public var supportsWorkspaceCreateInGroup: Bool { supportedHostCapabilities.contains(Self.workspaceCreateInGroupCapability) && allowsMacScopedWorkspaceMutations } + /// Whether the Mac supports creating workspace groups from iOS. + public var supportsWorkspaceGroupCreate: Bool { supportedHostCapabilities.contains(Self.workspaceGroupCreateCapability) && allowsMacScopedWorkspaceMutations } /// Whether the Mac supports dogfood feedback submission. public var supportsDogfoodFeedback: Bool { supportedHostCapabilities.contains(Self.dogfoodFeedbackCapability) } /// Bumped whenever the applied terminal theme actually changes (a connect @@ -334,7 +335,6 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// preserved across a theme change. The counter only advances on a real /// value change, so an unchanged theme on reconnect does no work. public private(set) var terminalThemeGeneration: UInt64 = 0 - /// Applies the Mac's reported terminal theme to the process-wide /// ``TerminalThemeStore`` and, when the resolved value actually changes, /// bumps ``terminalThemeGeneration`` so the mounted terminal surface (and diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceCapabilityTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceCapabilityTests.swift index 24f26d3f97b1..78b8dc58d8d4 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceCapabilityTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceCapabilityTests.swift @@ -17,6 +17,7 @@ import Testing #expect(!oldMac.store.supportsWorkspaceReadStateActions && !oldMac.store.supportsWorkspaceCloseActions) #expect(!oldMac.store.supportsWorkspaceMoveActions && !oldMac.store.supportsWorkspaceGroupActions) #expect(!oldMac.store.supportsWorkspaceCreateInGroup) + #expect(!oldMac.store.supportsWorkspaceGroupCreate) let currentCapabilities = [ "events.v1", @@ -28,11 +29,13 @@ import Testing "workspace.move.v1", "workspace.group_actions.v1", "workspace.create_in_group.v1", + "workspace.group_create.v1", ] let scoped = try await connectedStore(capabilities: currentCapabilities) #expect(scoped.store.supportsWorkspaceReadStateActions && scoped.store.supportsWorkspaceCloseActions) #expect(!scoped.store.supportsWorkspaceMoveActions && !scoped.store.supportsWorkspaceGroupActions) #expect(!scoped.store.supportsWorkspaceCreateInGroup) + #expect(!scoped.store.supportsWorkspaceGroupCreate) let macWide = try await connectedStore( capabilities: currentCapabilities, @@ -41,6 +44,7 @@ import Testing ) #expect(macWide.store.supportsWorkspaceMoveActions && macWide.store.supportsWorkspaceGroupActions) #expect(macWide.store.supportsWorkspaceCreateInGroup) + #expect(macWide.store.supportsWorkspaceGroupCreate) } @Test func staleMacScopedMutationCapabilitiesFailClosedAfterTicketExpires() async throws { @@ -52,6 +56,7 @@ import Testing "workspace.move.v1", "workspace.group_actions.v1", "workspace.create_in_group.v1", + "workspace.group_create.v1", ], ticketWorkspaceID: "", ticketTerminalID: nil, @@ -81,9 +86,13 @@ import Testing guard case .failure(.authorizationFailed) = await store.createWorkspaceRequest(inGroup: "group-a") else { return #expect(Bool(false), "expired ticket should fail create-in-group before sending") } + guard case .failure(.authorizationFailed) = await store.createWorkspaceGroup() else { + return #expect(Bool(false), "expired ticket should fail group create before sending") + } #expect(await router.count(of: "workspace.move") == 0) #expect(await router.count(of: "workspace.group.action") == 0) #expect(await router.count(of: "workspace.create") == 0) + #expect(await router.count(of: "workspace.group.create") == 0) } private func connectedStore( diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceActionCapabilities.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceActionCapabilities.swift index 913b018ace97..d95da1c21b5a 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceActionCapabilities.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceActionCapabilities.swift @@ -10,6 +10,8 @@ public struct MobileWorkspaceActionCapabilities: Equatable, Sendable { public var supportsMoveActions: Bool /// Whether workspace group mutation requests are supported. public var supportsGroupActions: Bool + /// Whether workspace group creation requests are supported. + public var supportsGroupCreate: Bool /// No workspace actions are supported. public static let none = MobileWorkspaceActionCapabilities() @@ -20,12 +22,14 @@ public struct MobileWorkspaceActionCapabilities: Equatable, Sendable { supportsReadStateActions: Bool = false, supportsCloseActions: Bool = false, supportsMoveActions: Bool = false, - supportsGroupActions: Bool = false + supportsGroupActions: Bool = false, + supportsGroupCreate: Bool = false ) { self.supportsWorkspaceActions = supportsWorkspaceActions self.supportsReadStateActions = supportsReadStateActions self.supportsCloseActions = supportsCloseActions self.supportsMoveActions = supportsMoveActions self.supportsGroupActions = supportsGroupActions + self.supportsGroupCreate = supportsGroupCreate } } diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListItem+MoveIntent.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListItem+MoveIntent.swift index 8ffb52184c09..67f6aed66ed4 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListItem+MoveIntent.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListItem+MoveIntent.swift @@ -36,11 +36,14 @@ extension Array where Element == MobileWorkspacePreview { /// - Parameters: /// - intent: The move intent derived from the rendered list snapshot. /// - movedWorkspaceID: The dragged workspace, or a moved group's anchor workspace. + /// - groups: The group snapshots used to preserve group membership and ordering. /// - Returns: A workspace snapshot with the move applied. public func applyingWorkspaceMoveIntent( _ intent: MobileWorkspaceMoveIntent, - movedWorkspaceID: MobileWorkspacePreview.ID + movedWorkspaceID: MobileWorkspacePreview.ID, + groups: [MobileWorkspaceGroupPreview] ) -> [MobileWorkspacePreview] { - MobileWorkspaceOrderMoveApplier(workspaces: self).applying(intent, movedWorkspaceID: movedWorkspaceID) + MobileWorkspaceOrderMoveApplier(workspaces: self, groups: groups) + .applying(intent, movedWorkspaceID: movedWorkspaceID) } } diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListItem.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListItem.swift index 715d6a4a4b3d..83465ade17b2 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListItem.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListItem.swift @@ -19,13 +19,16 @@ public enum MobileWorkspaceListItem: Identifiable, Equatable, Sendable { /// collapsed it reflects the whole group, anchor included, so hidden /// member activity is never silently swallowed. case groupHeader(MobileWorkspaceGroupPreview, hasUnread: Bool) - /// A synthetic footer marking the end of an expanded group's member run. - /// Rendered only in the grouped mobile list so end-of-group insertion gaps - /// split into distinct "inside the group" and "top level" targets. - case groupFooter(MobileWorkspaceGroupPreview.ID) /// 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) + /// The end-of-group drop slot after an expanded group's last visible + /// member. Emitted only for groups with at least one member row, so drops + /// before it join the group at its end and drops after it land at root — + /// the direct touch equivalent of the Mac's boundary pointer lane. Empty + /// and collapsed groups emit no slot (their below-header gap already + /// resolves), which keeps header stacks free of thin flapping targets. + case groupFooter(MobileWorkspaceGroupPreview.ID) /// A stable, list-unique identity for SwiftUI diffing. Namespaced by item /// kind (`group.` / `workspace.`) so a group header and a workspace row can @@ -34,10 +37,10 @@ public enum MobileWorkspaceListItem: Identifiable, Equatable, Sendable { switch self { case .groupHeader(let group, _): return "group.\(group.id.rawValue)" - case .groupFooter(let groupID): - return "groupFooter.\(groupID.rawValue)" case .workspace(let workspace, _): return "workspace.\(workspace.id.rawValue)" + case .groupFooter(let groupID): + return "groupFooter.\(groupID.rawValue)" } } @@ -47,11 +50,11 @@ public enum MobileWorkspaceListItem: Identifiable, Equatable, Sendable { /// - Items follow `workspaces` order. A group header is emitted at the first /// member's position. /// - The anchor workspace is never a separate row (the header represents it). - /// - Expanded groups append a synthetic ``groupFooter(_:)`` after their last - /// visible member so `List.onMove` has separate insertion gaps for - /// "append inside the group" and "place at the top level". - /// - When a group is collapsed, its members are skipped (header kept) and no - /// footer is emitted. + /// - Expanded groups emit their visible non-anchor members directly after + /// the header, followed by one end-of-group drop slot when the run + /// rendered at least one member row. Anchor-only groups, collapsed + /// groups, and non-contiguous second runs emit no slot. + /// - When a group is collapsed, its members are skipped and the header remains. /// - Ungrouped workspaces interleave inline by position. /// /// A `groupID` referencing a group not present in `groups` (e.g. a transient @@ -89,24 +92,29 @@ public enum MobileWorkspaceListItem: Identifiable, Equatable, Sendable { } } - var lastVisibleNonAnchorWorkspaceIDByGroupID: [MobileWorkspaceGroupPreview.ID: MobileWorkspacePreview.ID] = [:] - var hasVisibleNonAnchorByGroupID: [MobileWorkspaceGroupPreview.ID: Bool] = [:] - for workspace in workspaces { - guard let groupID = workspace.groupID, - let group = groupsByID[groupID], - !group.isCollapsed, - group.anchorWorkspaceID != workspace.id else { - continue - } - hasVisibleNonAnchorByGroupID[groupID] = true - lastVisibleNonAnchorWorkspaceIDByGroupID[groupID] = workspace.id - } - var items: [MobileWorkspaceListItem] = [] - items.reserveCapacity(workspaces.count + (groups.count * 2)) + items.reserveCapacity(workspaces.count) var lastEmittedGroupID: MobileWorkspaceGroupPreview.ID? var emittedHeaders: Set = [] + var emittedFooters: Set = [] var collapsedByGroupID: [MobileWorkspaceGroupPreview.ID: Bool] = [:] + var memberRowsInCurrentRun = 0 + + // Close a group run with its end-of-group slot when the run rendered + // at least one member row. Footers are per-group unique (ForEach ids), + // so a non-contiguous second run never emits another. + func flushGroupFooter() { + guard let groupID = lastEmittedGroupID, + memberRowsInCurrentRun > 0, + collapsedByGroupID[groupID] != true, + !emittedFooters.contains(groupID) else { + memberRowsInCurrentRun = 0 + return + } + items.append(.groupFooter(groupID)) + emittedFooters.insert(groupID) + memberRowsInCurrentRun = 0 + } for workspace in workspaces { // Resolve the membership only when the referenced group actually @@ -115,15 +123,13 @@ public enum MobileWorkspaceListItem: Identifiable, Equatable, Sendable { .flatMap { groupsByID[$0] != nil ? $0 : nil } if groupID != lastEmittedGroupID { + flushGroupFooter() 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)) - if !group.isCollapsed, hasVisibleNonAnchorByGroupID[groupID] != true { - items.append(.groupFooter(groupID)) - } emittedHeaders.insert(groupID) collapsedByGroupID[groupID] = group.isCollapsed } @@ -137,12 +143,12 @@ public enum MobileWorkspaceListItem: Identifiable, Equatable, Sendable { let isCollapsed = groupID.map { collapsedByGroupID[$0] ?? false } ?? false if groupID == nil || !isCollapsed { items.append(.workspace(workspace, indented: groupID != nil)) - if let groupID, - lastVisibleNonAnchorWorkspaceIDByGroupID[groupID] == workspace.id { - items.append(.groupFooter(groupID)) + if groupID != nil, !isCollapsed { + memberRowsInCurrentRun += 1 } } } + flushGroupFooter() return items } } diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListMoveIntentResolver.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListMoveIntentResolver.swift index 38e475e2b5e9..4738689851a5 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListMoveIntentResolver.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListMoveIntentResolver.swift @@ -1,6 +1,32 @@ import Foundation /// Resolves rendered iOS workspace list drags into Mac-facing move intents. +/// +/// Policy table, mirroring the Mac sidebar/host write path: +/// - Group headers move the whole group as a top-level row only; drops that +/// visually land inside another group normalize to that group's anchor +/// boundary. See `SidebarWorkspaceReorderDropResolver` group-header routing +/// and `TerminalController.mobileWorkspaceMoveTopLevelBeforeID`. +/// - Group anchors are rendered only as headers. Dragging the header moves the +/// group; workspace-row drags never change an anchor's membership. +/// - The gap below an expanded header joins that group, including anchor-only +/// groups. Header/member and same-group member/member gaps also stay in-group. +/// - Populated expanded groups render an end-of-group slot: dropping before it +/// joins the group at its end (members and external workspaces alike), and +/// dropping after it lands at root right after the group — the direct touch +/// equivalent of the Mac's boundary pointer lane. +/// - When no slot exists (empty groups, collapsed groups, or item arrays +/// without footers), the mixed gap after a group's last member falls back to +/// Mac neighbor inference: a workspace already in that group stays at its +/// end; any other workspace lands at root before the next top-level row. See +/// `WorkspaceReorderCoordinator.applyDragInferredGroupMembership` lines 421-457. +/// - Collapsed group headers expose only a top-level root slot. +/// - Pinned top-level rows form a leading tier. A moved workspace uses its own +/// pin state; a moved group uses the group's pin state. In-group member order +/// is anchor first, pinned members, then unpinned members. +/// - Unknown groups or before-workspace IDs are rejected before an RPC can be +/// emitted, matching the host's `not_found`/invalid-request behavior without +/// producing a doomed optimistic order. struct MobileWorkspaceListMoveIntentResolver { let items: [MobileWorkspaceListItem] let workspaces: [MobileWorkspacePreview] @@ -38,7 +64,6 @@ struct MobileWorkspaceListMoveIntentResolver { remainingItems.endIndex ) - let currentGroupID = validGroupID(movedWorkspace.groupID) let orderedWithoutMoved = workspaces.filter { $0.id != movedWorkspace.id } let previousItem = insertionIndex > remainingItems.startIndex ? remainingItems[remainingItems.index(before: insertionIndex)] @@ -51,31 +76,16 @@ struct MobileWorkspaceListMoveIntentResolver { let proposed = movesGroup ? rootLevelIntent(nextItem: nextItem, workspaces: orderedWithoutMoved) : proposedIntent( + movedWorkspace: movedWorkspace, previousItem: previousItem, nextItem: nextItem, workspaces: orderedWithoutMoved ) - guard let intent = proposed else { return nil } - let changesWorkspaceOrder = if movesGroup { - currentGroupID.map { - changesGroupOrder( - movedGroupID: $0, - beforeWorkspaceID: intent.beforeWorkspaceID - ) - } ?? false - } else { - intent.groupID != currentGroupID || changesOrder( - draggedWorkspaceID: movedWorkspace.id, - beforeWorkspaceID: intent.beforeWorkspaceID - ) - } - guard changesWorkspaceOrder else { return nil } - return MobileWorkspaceMoveIntent( - groupID: intent.groupID, - beforeWorkspaceID: intent.beforeWorkspaceID, - movesGroup: movesGroup - ) + guard var proposed else { return nil } + proposed.movesGroup = movesGroup + return MobileWorkspaceMovePolicy(workspaces: workspaces, groups: groups) + .normalizedIntent(proposed, movedWorkspaceID: movedWorkspace.id) } private func movedWorkspace(for item: MobileWorkspaceListItem) -> MobileWorkspacePreview? { @@ -97,6 +107,7 @@ struct MobileWorkspaceListMoveIntentResolver { } private func proposedIntent( + movedWorkspace: MobileWorkspacePreview, previousItem: MobileWorkspaceListItem?, nextItem: MobileWorkspaceListItem?, workspaces: [MobileWorkspacePreview] @@ -130,7 +141,12 @@ struct MobileWorkspaceListMoveIntentResolver { beforeWorkspaceID: nextWorkspace.id ) } - return rootLevelIntent(nextItem: nextItem, workspaces: workspaces) + return intentAfterLastGroupMember( + movedWorkspace: movedWorkspace, + groupID: previousGroupID, + nextItem: nextItem, + workspaces: workspaces + ) case .groupFooter(let footerGroupID): guard footerGroupID == previousGroupID else { @@ -142,7 +158,12 @@ struct MobileWorkspaceListMoveIntentResolver { ) case .groupHeader, nil: - return rootLevelIntent(nextItem: nextItem, workspaces: workspaces) + return intentAfterLastGroupMember( + movedWorkspace: movedWorkspace, + groupID: previousGroupID, + nextItem: nextItem, + workspaces: workspaces + ) } case nil: @@ -150,6 +171,21 @@ struct MobileWorkspaceListMoveIntentResolver { } } + private func intentAfterLastGroupMember( + movedWorkspace: MobileWorkspacePreview, + groupID: MobileWorkspaceGroupPreview.ID, + nextItem: MobileWorkspaceListItem?, + workspaces: [MobileWorkspacePreview] + ) -> MobileWorkspaceMoveIntent { + guard validGroupID(movedWorkspace.groupID) == groupID else { + return rootLevelIntent(nextItem: nextItem, workspaces: workspaces) + } + return MobileWorkspaceMoveIntent( + groupID: groupID, + beforeWorkspaceID: workspaceAfterGroup(groupID, workspaces: workspaces) + ) + } + private func rootLevelIntent( nextItem: MobileWorkspaceListItem?, workspaces: [MobileWorkspacePreview] @@ -215,25 +251,4 @@ struct MobileWorkspaceListMoveIntentResolver { guard nextIndex < workspaces.endIndex else { return nil } return workspaces[nextIndex].id } - - private func changesOrder( - draggedWorkspaceID: MobileWorkspacePreview.ID, - beforeWorkspaceID: MobileWorkspacePreview.ID? - ) -> Bool { - var ids = workspaces.map(\.id) - guard let currentIndex = ids.firstIndex(of: draggedWorkspaceID) else { return false } - ids.remove(at: currentIndex) - let targetIndex = beforeWorkspaceID.flatMap { ids.firstIndex(of: $0) } ?? ids.endIndex - ids.insert(draggedWorkspaceID, at: targetIndex) - return ids != workspaces.map(\.id) - } - - private func changesGroupOrder( - movedGroupID: MobileWorkspaceGroupPreview.ID, - beforeWorkspaceID: MobileWorkspacePreview.ID? - ) -> Bool { - MobileWorkspaceOrderMoveApplier(workspaces: workspaces) - .applyingGroupMove(movedGroupID: movedGroupID, beforeWorkspaceID: beforeWorkspaceID) - .map(\.id) != workspaces.map(\.id) - } } diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceMovePolicy.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceMovePolicy.swift new file mode 100644 index 000000000000..9508f81579a1 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceMovePolicy.swift @@ -0,0 +1,471 @@ +import Foundation + +/// Host-style workspace move normalization shared by iOS intent resolution and optimistic ordering. +struct MobileWorkspaceMovePolicy { + let workspaces: [MobileWorkspacePreview] + let groups: [MobileWorkspaceGroupPreview] + // One synchronous drop runs the policy across per-workspace filters, + // clamps, and signatures; the lookup tables are built once here instead of + // per access. + private let groupsByID: [MobileWorkspaceGroupPreview.ID: MobileWorkspaceGroupPreview] + private let groupByAnchorID: [MobileWorkspacePreview.ID: MobileWorkspaceGroupPreview] + + init(workspaces: [MobileWorkspacePreview], groups: [MobileWorkspaceGroupPreview]) { + self.workspaces = workspaces + self.groups = groups + groupsByID = Dictionary(groups.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + groupByAnchorID = Dictionary(groups.map { ($0.anchorWorkspaceID, $0) }, uniquingKeysWith: { first, _ in first }) + } + + func normalizedIntent( + _ proposed: MobileWorkspaceMoveIntent, + movedWorkspaceID: MobileWorkspacePreview.ID + ) -> MobileWorkspaceMoveIntent? { + guard workspaces.contains(where: { $0.id == movedWorkspaceID }) else { return nil } + guard proposed.beforeWorkspaceID.map({ beforeID in + workspaces.contains(where: { $0.id == beforeID }) + }) ?? true else { + return nil + } + if let groupID = proposed.groupID, groupsByID[groupID] == nil { + return nil + } + if proposed.movesGroup { + guard groupByAnchorID[movedWorkspaceID] != nil, proposed.groupID == nil else { + return nil + } + } else if proposed.groupID != nil, groupByAnchorID[movedWorkspaceID] != nil { + return nil + } + + let predicted = applyingHostMove(proposed, movedWorkspaceID: movedWorkspaceID) + guard orderSignature(predicted) != orderSignature(workspaces) else { return nil } + + if proposed.movesGroup { + return MobileWorkspaceMoveIntent( + groupID: nil, + beforeWorkspaceID: topLevelBeforeWorkspaceID(afterMoving: movedWorkspaceID, in: predicted), + movesGroup: true + ) + } + let moved = predicted.first { $0.id == movedWorkspaceID } + return MobileWorkspaceMoveIntent( + groupID: validGroupID(moved?.groupID), + beforeWorkspaceID: workspaceID(after: movedWorkspaceID, in: predicted), + movesGroup: false + ) + } + + func applyingHostMove( + _ intent: MobileWorkspaceMoveIntent, + movedWorkspaceID: MobileWorkspacePreview.ID + ) -> [MobileWorkspacePreview] { + var order = normalizedGroupRuns(workspaces) + guard let movedIndex = order.firstIndex(where: { $0.id == movedWorkspaceID }) else { + return order + } + if intent.movesGroup { + guard groupByAnchorID[movedWorkspaceID] != nil else { return order } + return applyingTopLevelMove( + movedWorkspaceID: movedWorkspaceID, + beforeWorkspaceID: intent.beforeWorkspaceID, + in: order, + promotesGroupedWorkspace: false + ) + } + + let moved = order[movedIndex] + if validGroupID(moved.groupID) != intent.groupID { + guard !isAnchor(movedWorkspaceID) else { return order } + if let targetGroupID = intent.groupID { + let originalTopLevelIds = topLevelWorkspaceIDs(in: order) + order[movedIndex].groupID = targetGroupID + order = normalizedGroupRuns( + order, + preservingTopLevelIDs: originalTopLevelIds.filter { $0 != movedWorkspaceID } + ) + } else { + order[movedIndex].groupID = nil + order = normalizedGroupRuns(order) + } + } + + if let beforeWorkspaceID = intent.beforeWorkspaceID { + order = applyingWorkspaceReorder( + movedWorkspaceID: movedWorkspaceID, + beforeWorkspaceID: beforeWorkspaceID, + in: order + ) + } else if let targetGroupID = intent.groupID { + order = applyingWorkspaceReorderToGroupEnd( + movedWorkspaceID: movedWorkspaceID, + groupID: targetGroupID, + in: order + ) + } else { + order = applyingWorkspaceReorder( + movedWorkspaceID: movedWorkspaceID, + targetIndex: order.endIndex, + in: order + ) + } + return normalizedGroupRuns(order) + } + + private func applyingTopLevelMove( + movedWorkspaceID: MobileWorkspacePreview.ID, + beforeWorkspaceID: MobileWorkspacePreview.ID?, + in order: [MobileWorkspacePreview], + promotesGroupedWorkspace: Bool + ) -> [MobileWorkspacePreview] { + var mutable = order + let topLevelIds = topLevelWorkspaceIDs( + in: mutable, + promotingWorkspaceID: promotesGroupedWorkspace ? movedWorkspaceID : nil + ) + guard let fromIndex = topLevelIds.firstIndex(of: movedWorkspaceID) else { + return mutable + } + let normalizedBeforeID = topLevelNormalizedBeforeWorkspaceID(beforeWorkspaceID, in: mutable) + let insertionPosition = normalizedBeforeID.flatMap { topLevelIds.firstIndex(of: $0) } ?? topLevelIds.count + let adjustedIndex = insertionPosition > fromIndex ? insertionPosition - 1 : insertionPosition + let targetIndex = clampedTopLevelIndex( + movedWorkspaceID: movedWorkspaceID, + targetIndex: adjustedIndex, + topLevelIds: topLevelIds, + in: mutable + ) + + var desiredTopLevelIds = topLevelIds + if fromIndex != targetIndex { + let movedID = desiredTopLevelIds.remove(at: fromIndex) + desiredTopLevelIds.insert(movedID, at: targetIndex) + } + if promotesGroupedWorkspace, + let index = mutable.firstIndex(where: { $0.id == movedWorkspaceID }), + !isAnchor(movedWorkspaceID) { + mutable[index].groupID = nil + } + return normalizedGroupRuns(mutable, preservingTopLevelIDs: desiredTopLevelIds) + } + + private func applyingWorkspaceReorder( + movedWorkspaceID: MobileWorkspacePreview.ID, + beforeWorkspaceID: MobileWorkspacePreview.ID, + in order: [MobileWorkspacePreview] + ) -> [MobileWorkspacePreview] { + guard let currentIndex = order.firstIndex(where: { $0.id == movedWorkspaceID }), + let beforeIndex = order.firstIndex(where: { $0.id == beforeWorkspaceID }) else { + return order + } + let targetIndex = currentIndex < beforeIndex ? beforeIndex - 1 : beforeIndex + return applyingWorkspaceReorder( + movedWorkspaceID: movedWorkspaceID, + targetIndex: targetIndex, + in: order + ) + } + + private func applyingWorkspaceReorderToGroupEnd( + movedWorkspaceID: MobileWorkspacePreview.ID, + groupID: MobileWorkspaceGroupPreview.ID, + in order: [MobileWorkspacePreview] + ) -> [MobileWorkspacePreview] { + guard let lastMemberIndex = order.lastIndex(where: { + $0.id != movedWorkspaceID && validGroupID($0.groupID) == groupID + }) else { + return order + } + return applyingWorkspaceReorder( + movedWorkspaceID: movedWorkspaceID, + targetIndex: order.index(after: lastMemberIndex), + in: order + ) + } + + private func applyingWorkspaceReorder( + movedWorkspaceID: MobileWorkspacePreview.ID, + targetIndex: Int, + in order: [MobileWorkspacePreview] + ) -> [MobileWorkspacePreview] { + guard let currentIndex = order.firstIndex(where: { $0.id == movedWorkspaceID }) else { + return order + } + let clampedTargetIndex = clampedWorkspaceIndex( + for: order[currentIndex], + targetIndex: targetIndex, + in: order + ) + guard currentIndex != clampedTargetIndex else { return order } + var mutable = order + let moved = mutable.remove(at: currentIndex) + mutable.insert(moved, at: max(0, min(clampedTargetIndex, mutable.count))) + return mutable + } + + private func normalizedGroupRuns( + _ order: [MobileWorkspacePreview], + preservingTopLevelIDs preferredTopLevelIds: [MobileWorkspacePreview.ID]? = nil + ) -> [MobileWorkspacePreview] { + let knownGroupIds = Set(groups.map(\.id)) + var mutable = order.map { workspace in + var copy = workspace + if copy.groupID.map({ !knownGroupIds.contains($0) }) ?? false { + copy.groupID = nil + } + return copy + } + let topLevelIds = preferredTopLevelIds ?? topLevelWorkspaceIDs(in: mutable) + let pinnedTopLevelIds = pinnedTopLevelWorkspaceIDs(topLevelIds, in: mutable) + let desiredIds = topLevelIds.filter { pinnedTopLevelIds.contains($0) } + + topLevelIds.filter { !pinnedTopLevelIds.contains($0) } + mutable = normalizedGroupRuns(mutable, desiredWorkspaceIDs: desiredIds) + return mutable + } + + private func normalizedGroupRuns( + _ order: [MobileWorkspacePreview], + desiredWorkspaceIDs: [MobileWorkspacePreview.ID] + ) -> [MobileWorkspacePreview] { + let groupedByGroupID = Dictionary(grouping: order.filter { + validGroupID($0.groupID) != nil + }, by: { validGroupID($0.groupID)! }) + let workspacesByID = Dictionary(order.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + var emittedWorkspaceIDs = Set() + var emittedGroupIDs = Set() + var reordered: [MobileWorkspacePreview] = [] + reordered.reserveCapacity(order.count) + + func appendWorkspaceOrGroup(for id: MobileWorkspacePreview.ID) { + guard let workspace = workspacesByID[id] else { return } + if let groupID = validGroupID(workspace.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) + } + } else if validGroupID(workspace.groupID) == nil, + emittedWorkspaceIDs.insert(workspace.id).inserted { + var copy = workspace + copy.groupID = nil + reordered.append(copy) + } + } + + for id in desiredWorkspaceIDs { + appendWorkspaceOrGroup(for: id) + } + for workspace in order where !emittedWorkspaceIDs.contains(workspace.id) { + appendWorkspaceOrGroup(for: workspace.id) + } + return reordered + } + + private func topLevelWorkspaceIDs( + in order: [MobileWorkspacePreview], + promotingWorkspaceID promotedWorkspaceID: MobileWorkspacePreview.ID? = nil + ) -> [MobileWorkspacePreview.ID] { + let workspacesByID = Dictionary(order.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + var emittedGroupIDs = Set() + var ids: [MobileWorkspacePreview.ID] = [] + ids.reserveCapacity(order.count) + for workspace in order { + if let groupID = validGroupID(workspace.groupID), + let group = groupsByID[groupID] { + if emittedGroupIDs.insert(groupID).inserted { + ids.append(group.anchorWorkspaceID) + } + } else { + ids.append(workspace.id) + } + } + if let promotedWorkspaceID, + !ids.contains(promotedWorkspaceID), + let workspace = workspacesByID[promotedWorkspaceID], + let groupID = validGroupID(workspace.groupID), + let group = groupsByID[groupID], + let groupIndex = ids.firstIndex(of: group.anchorWorkspaceID) { + let insertionIndex = promotedTopLevelInsertionIndex( + ids: ids, + groupIndex: groupIndex, + promotedIsPinned: workspace.isPinned, + in: order + ) + ids.insert(promotedWorkspaceID, at: insertionIndex) + } + return ids + } + + private func pinnedTopLevelWorkspaceIDs( + _ ids: [MobileWorkspacePreview.ID], + in order: [MobileWorkspacePreview] + ) -> Set { + let workspacesByID = Dictionary(order.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + return Set(ids.filter { id in + groupByAnchorID[id]?.isPinned ?? (workspacesByID[id]?.isPinned == true) + }) + } + + private func promotedTopLevelInsertionIndex( + ids: [MobileWorkspacePreview.ID], + groupIndex: Int, + promotedIsPinned: Bool, + in order: [MobileWorkspacePreview] + ) -> Int { + let desiredIndex = min(groupIndex + 1, ids.count) + let pinnedIds = pinnedTopLevelWorkspaceIDs(ids, in: order) + let pinnedCount = ids.reduce(into: 0) { count, id in + if pinnedIds.contains(id) { + count += 1 + } + } + return promotedIsPinned ? min(desiredIndex, pinnedCount) : max(desiredIndex, pinnedCount) + } + + private func clampedTopLevelIndex( + movedWorkspaceID: MobileWorkspacePreview.ID, + targetIndex: Int, + topLevelIds: [MobileWorkspacePreview.ID], + in order: [MobileWorkspacePreview] + ) -> Int { + let clamped = max(0, min(targetIndex, max(0, topLevelIds.count - 1))) + let pinnedIds = pinnedTopLevelWorkspaceIDs(topLevelIds, in: order) + let pinnedCount = topLevelIds.reduce(into: 0) { count, id in + if pinnedIds.contains(id) { + count += 1 + } + } + if pinnedIds.contains(movedWorkspaceID) { + return min(clamped, max(0, pinnedCount - 1)) + } + return max(clamped, pinnedCount) + } + + private func clampedWorkspaceIndex( + for workspace: MobileWorkspacePreview, + targetIndex: Int, + in order: [MobileWorkspacePreview] + ) -> Int { + let clamped = max(0, min(targetIndex, max(0, order.count - 1))) + if let groupClamp = clampedGroupedMemberIndex(for: workspace, targetIndex: clamped, in: order) { + return groupClamp + } + let pinnedCount = leadingGlobalPinnedRowCount(in: order) + if workspace.isPinned { + return min(clamped, max(0, pinnedCount - 1)) + } + return max(clamped, pinnedCount) + } + + private func clampedGroupedMemberIndex( + for workspace: MobileWorkspacePreview, + targetIndex: Int, + in order: [MobileWorkspacePreview] + ) -> Int? { + guard let groupID = validGroupID(workspace.groupID), + let group = groupsByID[groupID], + workspace.id != group.anchorWorkspaceID else { + return nil + } + let memberIndices = order.indices.filter { validGroupID(order[$0].groupID) == groupID } + guard let firstIndex = memberIndices.first, + let lastIndex = memberIndices.last else { + return nil + } + let pinnedMemberCount = memberIndices.reduce(into: 0) { count, index in + let member = order[index] + if member.id != group.anchorWorkspaceID, member.isPinned { + count += 1 + } + } + let lowerBound = workspace.isPinned + ? min(firstIndex + 1, lastIndex) + : min(firstIndex + 1 + pinnedMemberCount, lastIndex) + let upperBound = workspace.isPinned + ? max(firstIndex + pinnedMemberCount, lowerBound) + : lastIndex + return min(max(targetIndex, lowerBound), upperBound) + } + + private func leadingGlobalPinnedRowCount(in order: [MobileWorkspacePreview]) -> Int { + var count = 0 + for workspace in order { + guard isGlobalPinnedRow(workspace) else { break } + count += 1 + } + return count + } + + private func isGlobalPinnedRow(_ workspace: MobileWorkspacePreview) -> Bool { + if let groupID = validGroupID(workspace.groupID), + let group = groupsByID[groupID] { + return group.isPinned + } + return workspace.isPinned + } + + private func topLevelNormalizedBeforeWorkspaceID( + _ beforeWorkspaceID: MobileWorkspacePreview.ID?, + in order: [MobileWorkspacePreview] + ) -> MobileWorkspacePreview.ID? { + guard let beforeWorkspaceID, + let beforeWorkspace = order.first(where: { $0.id == beforeWorkspaceID }), + let groupID = validGroupID(beforeWorkspace.groupID), + let group = groupsByID[groupID] else { + return beforeWorkspaceID + } + return group.anchorWorkspaceID + } + + private func topLevelBeforeWorkspaceID( + afterMoving movedWorkspaceID: MobileWorkspacePreview.ID, + in order: [MobileWorkspacePreview] + ) -> MobileWorkspacePreview.ID? { + let topLevelIds = topLevelWorkspaceIDs(in: order) + guard let index = topLevelIds.firstIndex(of: movedWorkspaceID) else { return nil } + let nextIndex = topLevelIds.index(after: index) + guard nextIndex < topLevelIds.endIndex else { return nil } + return topLevelIds[nextIndex] + } + + private func workspaceID( + after movedWorkspaceID: MobileWorkspacePreview.ID, + in order: [MobileWorkspacePreview] + ) -> MobileWorkspacePreview.ID? { + guard let index = order.firstIndex(where: { $0.id == movedWorkspaceID }) else { return nil } + let nextIndex = order.index(after: index) + guard nextIndex < order.endIndex else { return nil } + return order[nextIndex].id + } + + private func anchorFirst( + _ members: [MobileWorkspacePreview], + anchorID: MobileWorkspacePreview.ID + ) -> [MobileWorkspacePreview] { + guard let anchor = members.first(where: { $0.id == anchorID }) else { + return members + } + let nonAnchors = members.filter { $0.id != anchorID } + return [anchor] + nonAnchors.filter(\.isPinned) + nonAnchors.filter { !$0.isPinned } + } + + private func validGroupID( + _ groupID: MobileWorkspaceGroupPreview.ID? + ) -> MobileWorkspaceGroupPreview.ID? { + guard let groupID, groupsByID[groupID] != nil else { return nil } + return groupID + } + + private func isAnchor(_ workspaceID: MobileWorkspacePreview.ID) -> Bool { + groupByAnchorID[workspaceID] != nil + } + + private func orderSignature( + _ order: [MobileWorkspacePreview] + ) -> [MobileWorkspaceOrderSignature] { + order.map { + MobileWorkspaceOrderSignature(id: $0.id, groupID: validGroupID($0.groupID), isPinned: $0.isPinned) + } + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOptimisticOrder.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOptimisticOrder.swift new file mode 100644 index 000000000000..256bbf21ce4b --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOptimisticOrder.swift @@ -0,0 +1,92 @@ +import Foundation + +/// An optimistic workspace order that retains identity and intended membership only. +public struct MobileWorkspaceOptimisticOrder: Equatable, Sendable { + /// Ordered workspace identities with their drag-intended memberships. + public let entries: [MobileWorkspaceOptimisticOrderEntry] + /// The group pin states the prediction was computed against. + public let groupPins: [MobileWorkspaceGroupPreview.ID: Bool] + + /// Captures ordering intent from a workspace snapshot without retaining live row content. + /// - Parameters: + /// - workspaces: The predicted workspace sequence. + /// - groups: The groups the prediction was computed against; their pin + /// states participate in staleness checks like workspace pins do. + public init(workspaces: [MobileWorkspacePreview], groups: [MobileWorkspaceGroupPreview] = []) { + entries = workspaces.map { + MobileWorkspaceOptimisticOrderEntry(id: $0.id, groupID: $0.groupID, isPinned: $0.isPinned) + } + groupPins = Dictionary(groups.map { ($0.id, $0.isPinned) }, uniquingKeysWith: { first, _ in first }) + } + + /// Rebuilds the optimistic sequence from current authoritative row values. + /// + /// Existing rows keep the optimistic order and intended membership. Deleted + /// rows disappear. New authoritative rows are inserted beside their nearest + /// authoritative predecessor or successor, preserving their live content. + /// - Parameter authoritative: The current authoritative workspace snapshot. + /// - Returns: Live workspace values projected through this ordering intent. + public func materializedWorkspaces( + from authoritative: [MobileWorkspacePreview] + ) -> [MobileWorkspacePreview] { + let authoritativeByID = Dictionary( + authoritative.map { ($0.id, $0) }, + uniquingKeysWith: { first, _ in first } + ) + var result = entries.compactMap { entry -> MobileWorkspacePreview? in + guard var workspace = authoritativeByID[entry.id] else { return nil } + workspace.groupID = entry.groupID + return workspace + } + var resultIDs = Set(result.map(\.id)) + + for (index, workspace) in authoritative.enumerated() where !resultIDs.contains(workspace.id) { + let precedingID = authoritative[.. Bool { + for workspace in authoritative { + if let entry = entries.first(where: { $0.id == workspace.id }), + entry.isPinned != workspace.isPinned { + return false + } + } + for group in groups { + if let capturedPin = groupPins[group.id], capturedPin != group.isPinned { + return false + } + } + return Self(workspaces: materializedWorkspaces(from: authoritative)) + == Self(workspaces: authoritative) + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOptimisticOrderEntry.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOptimisticOrderEntry.swift new file mode 100644 index 000000000000..b354502ca4ba --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOptimisticOrderEntry.swift @@ -0,0 +1,27 @@ +import Foundation + +/// One workspace position and its drag-intended group membership. +public struct MobileWorkspaceOptimisticOrderEntry: Equatable, Sendable { + /// The workspace identity retained across live snapshot refreshes. + public let id: MobileWorkspacePreview.ID + /// The membership predicted by the optimistic move. + public let groupID: MobileWorkspaceGroupPreview.ID? + /// The pin state the prediction was computed against. A Mac-side pin + /// change alters legal ordering, so it invalidates the prediction. + public let isPinned: Bool + + /// Creates an optimistic ordering entry. + /// - Parameters: + /// - id: The workspace identity. + /// - groupID: The membership predicted by the move. + /// - isPinned: The pin state the prediction assumed. + public init( + id: MobileWorkspacePreview.ID, + groupID: MobileWorkspaceGroupPreview.ID?, + isPinned: Bool + ) { + self.id = id + self.groupID = groupID + self.isPinned = isPinned + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOptimisticOrderReconciler.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOptimisticOrderReconciler.swift new file mode 100644 index 000000000000..b58c1ac4a1d4 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOptimisticOrderReconciler.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Reconciles a pipelined optimistic workspace order with host snapshots. +/// +/// Successful move replies can arrive before the authoritative workspace +/// snapshot. In that window, clearing optimistic state makes the list snap +/// back. Every move therefore records its source order. A snapshot matching +/// any recorded intermediate keeps the displayed prediction and prunes older +/// intermediates; the displayed prediction drains the chain, while any other +/// order supersedes it. A failed move always clears the complete chain. +public struct MobileWorkspaceOptimisticOrderReconciler { + /// The ID/membership order currently displayed by the UI. + public let optimisticOrder: MobileWorkspaceOptimisticOrder? + /// Source orders for the outstanding serialized move chain. + public let pendingBases: [MobileWorkspaceOptimisticOrder] + + /// Creates optimistic reconciliation state. + /// - Parameters: + /// - optimisticOrder: The order currently displayed, if any. + /// - pendingBases: Source orders for all outstanding moves, oldest first. + public init( + optimisticOrder: MobileWorkspaceOptimisticOrder? = nil, + pendingBases: [MobileWorkspaceOptimisticOrder] = [] + ) { + self.optimisticOrder = optimisticOrder + self.pendingBases = pendingBases + } + + /// Returns the next reconciliation state for an authoritative snapshot. + /// - Parameters: + /// - authoritative: The latest host workspace snapshot. + /// - groups: The current groups, for pin-tier staleness checks. + /// - moveDidFail: Whether any move in the dependent chain failed. + public func reconciling( + authoritative: [MobileWorkspacePreview], + groups: [MobileWorkspaceGroupPreview] = [], + moveDidFail: Bool = false + ) -> MobileWorkspaceOptimisticOrderReconciler { + guard !moveDidFail, let optimisticOrder else { return .init() } + guard !optimisticOrder.matches(authoritative: authoritative, groups: groups) else { return .init() } + guard let matchedIndex = pendingBases.firstIndex(where: { + $0.matches(authoritative: authoritative, groups: groups) + }) else { + return .init() + } + // Keep the matched order because it is the source of the next + // outstanding move; only strictly older snapshots are now obsolete. + return .init( + optimisticOrder: optimisticOrder, + pendingBases: Array(pendingBases[matchedIndex...]) + ) + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOrderMoveApplier.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOrderMoveApplier.swift index 3100788181bc..2cce13d798a2 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOrderMoveApplier.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOrderMoveApplier.swift @@ -1,61 +1,13 @@ /// Applies pending workspace move intents to a local workspace snapshot. struct MobileWorkspaceOrderMoveApplier { let workspaces: [MobileWorkspacePreview] + let groups: [MobileWorkspaceGroupPreview] func applying( _ intent: MobileWorkspaceMoveIntent, movedWorkspaceID: MobileWorkspacePreview.ID ) -> [MobileWorkspacePreview] { - if intent.movesGroup, - let movedGroupID = workspaces.first(where: { $0.id == movedWorkspaceID })?.groupID { - return applyingGroupMove( - movedGroupID: movedGroupID, - beforeWorkspaceID: intent.beforeWorkspaceID - ) - } - guard let currentIndex = workspaces.firstIndex(where: { $0.id == movedWorkspaceID }) else { - return workspaces - } - var moved = workspaces[currentIndex] - moved.groupID = intent.groupID - var remaining = workspaces - remaining.remove(at: currentIndex) - let insertionIndex: Int - if let beforeWorkspaceID = intent.beforeWorkspaceID, - let targetIndex = remaining.firstIndex(where: { $0.id == beforeWorkspaceID }) { - insertionIndex = targetIndex - } else if let groupID = intent.groupID, - let lastMemberIndex = remaining.lastIndex(where: { $0.groupID == groupID }) { - insertionIndex = remaining.index(after: lastMemberIndex) - } else { - insertionIndex = remaining.endIndex - } - remaining.insert(moved, at: insertionIndex) - return remaining - } - - func applyingGroupMove( - movedGroupID: MobileWorkspaceGroupPreview.ID, - beforeWorkspaceID: MobileWorkspacePreview.ID? - ) -> [MobileWorkspacePreview] { - let movedGroup = workspaces.filter { $0.groupID == movedGroupID } - guard !movedGroup.isEmpty else { return workspaces } - var remaining = workspaces.filter { $0.groupID != movedGroupID } - let insertionIndex: Int - if let beforeWorkspaceID, - let beforeWorkspace = remaining.first(where: { $0.id == beforeWorkspaceID }) { - let beforeGroupID = beforeWorkspace.groupID - insertionIndex = remaining.firstIndex { - if let beforeGroupID { - $0.groupID == beforeGroupID - } else { - $0.id == beforeWorkspaceID - } - } ?? remaining.endIndex - } else { - insertionIndex = remaining.endIndex - } - remaining.insert(contentsOf: movedGroup, at: insertionIndex) - return remaining + MobileWorkspaceMovePolicy(workspaces: workspaces, groups: groups) + .applyingHostMove(intent, movedWorkspaceID: movedWorkspaceID) } } diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOrderSignature.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOrderSignature.swift new file mode 100644 index 000000000000..4cab517dac43 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOrderSignature.swift @@ -0,0 +1,37 @@ +import Foundation + +/// The stable ordering fields for a mobile workspace snapshot. +public struct MobileWorkspaceOrderSignature: Equatable, Sendable { + /// The workspace identity. + public var id: MobileWorkspacePreview.ID + /// The valid group membership used for ordering. + public var groupID: MobileWorkspaceGroupPreview.ID? + /// Whether the workspace belongs to the pinned tier. + public var isPinned: Bool + + /// Creates an order signature. + /// - Parameters: + /// - id: The workspace identity. + /// - groupID: The valid group membership used for ordering. + /// - isPinned: Whether the workspace belongs to the pinned tier. + public init( + id: MobileWorkspacePreview.ID, + groupID: MobileWorkspaceGroupPreview.ID?, + isPinned: Bool + ) { + self.id = id + self.groupID = groupID + self.isPinned = isPinned + } + + /// Returns the stable order signature for a snapshot, ignoring live row + /// content so reconciliation compares ordering fields only. + /// - Parameter workspaces: The workspace snapshot to compare. + public static func signature( + _ workspaces: [MobileWorkspacePreview] + ) -> [MobileWorkspaceOrderSignature] { + workspaces.map { + MobileWorkspaceOrderSignature(id: $0.id, groupID: $0.groupID, isPinned: $0.isPinned) + } + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceHostOrderSimulator.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceHostOrderSimulator.swift new file mode 100644 index 000000000000..5af1ed512f26 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceHostOrderSimulator.swift @@ -0,0 +1,340 @@ +import Foundation + +@testable import CmuxMobileShellModel + +/// Independent test oracle for the Mac host's mobile workspace move pipeline. +/// +/// This deliberately does not call `MobileWorkspaceMovePolicy`. It follows the +/// host phases in `TerminalController+WorkspaceMove.swift:73-147,183-218`, +/// `WorkspaceGroupCoordinator.swift:207-275`, +/// `WorkspaceReorderCoordinator.swift:164-202,283-350`, +/// `WorkspacesModel+Ordering.swift:37-160,168-243`, and +/// `WorkspacesModel+GroupInvariants.swift:19-87`. +struct MobileWorkspaceHostOrderSimulator { + let workspaces: [MobileWorkspacePreview] + let groups: [MobileWorkspaceGroupPreview] + + private var groupsByID: [MobileWorkspaceGroupPreview.ID: MobileWorkspaceGroupPreview] { + Dictionary(groups.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + } + + private var groupsByAnchorID: [MobileWorkspacePreview.ID: MobileWorkspaceGroupPreview] { + Dictionary(groups.map { ($0.anchorWorkspaceID, $0) }, uniquingKeysWith: { first, _ in first }) + } + + func applying( + _ intent: MobileWorkspaceMoveIntent, + movedWorkspaceID: MobileWorkspacePreview.ID + ) -> [MobileWorkspacePreview] { + var order = normalized(workspaces) + guard let movedIndex = order.firstIndex(where: { $0.id == movedWorkspaceID }) else { + return order + } + if intent.movesGroup { + guard groupsByAnchorID[movedWorkspaceID] != nil, intent.groupID == nil else { + return order + } + return applyingGroupMove( + movedWorkspaceID: movedWorkspaceID, + beforeWorkspaceID: intent.beforeWorkspaceID, + to: order + ) + } + + guard intent.groupID.map({ groupsByID[$0] != nil }) ?? true else { return order } + let currentGroupID = validGroupID(order[movedIndex].groupID) + if currentGroupID != intent.groupID { + guard groupsByAnchorID[movedWorkspaceID] == nil else { return order } + if let targetGroupID = intent.groupID { + let originalTopLevelIDs = topLevelIDs(order) + order[movedIndex].groupID = targetGroupID + order = normalized( + order, + preservingTopLevelIDs: originalTopLevelIDs.filter { $0 != movedWorkspaceID } + ) + order = placingNewMemberAtGroupEnd( + movedWorkspaceID, + groupID: targetGroupID, + in: order + ) + } else { + order[movedIndex].groupID = nil + order = normalized(order) + } + } + + if let beforeWorkspaceID = intent.beforeWorkspaceID { + order = reordered(movedWorkspaceID, before: beforeWorkspaceID, in: order) + } else if let targetGroupID = intent.groupID { + order = reorderedToGroupEnd(movedWorkspaceID, groupID: targetGroupID, in: order) + } else { + order = reordered(movedWorkspaceID, toIndex: order.endIndex, in: order) + } + return normalized(order) + } + + /// Mirrors the host's top-level target-index and whole-group reorder path. + private func applyingGroupMove( + movedWorkspaceID: MobileWorkspacePreview.ID, + beforeWorkspaceID: MobileWorkspacePreview.ID?, + to order: [MobileWorkspacePreview] + ) -> [MobileWorkspacePreview] { + let topLevelIDs = topLevelIDs(order) + guard let sourceIndex = topLevelIDs.firstIndex(of: movedWorkspaceID) else { return order } + let normalizedBeforeID = normalizedTopLevelBeforeID(beforeWorkspaceID, in: order) + let insertionPosition = normalizedBeforeID.flatMap { topLevelIDs.firstIndex(of: $0) } + ?? topLevelIDs.count + let clampedInsertion = max(0, min(insertionPosition, topLevelIDs.count)) + let adjustedIndex = clampedInsertion > sourceIndex ? clampedInsertion - 1 : clampedInsertion + let targetIndex = clampedTopLevelIndex( + movedWorkspaceID, + targetIndex: adjustedIndex, + topLevelIDs: topLevelIDs, + in: order + ) + guard sourceIndex != targetIndex else { return order } + var desiredIDs = topLevelIDs + let movedID = desiredIDs.remove(at: sourceIndex) + desiredIDs.insert(movedID, at: targetIndex) + return rebuilt(order, desiredTopLevelIDs: desiredIDs) + } + + /// Mirrors `addWorkspaceToGroup(... placement: .end)` before host reorder. + private func placingNewMemberAtGroupEnd( + _ movedWorkspaceID: MobileWorkspacePreview.ID, + groupID: MobileWorkspaceGroupPreview.ID, + in order: [MobileWorkspacePreview] + ) -> [MobileWorkspacePreview] { + guard let currentIndex = order.firstIndex(where: { $0.id == movedWorkspaceID }), + let group = groupsByID[groupID] else { + return order + } + let otherMemberIndices = order.indices.filter { + order[$0].id != movedWorkspaceID && validGroupID(order[$0].groupID) == groupID + } + let targetIndex: Int + if let lastMemberIndex = otherMemberIndices.last { + targetIndex = lastMemberIndex + 1 + } else if let anchorIndex = order.firstIndex(where: { $0.id == group.anchorWorkspaceID }) { + targetIndex = anchorIndex + 1 + } else { + return order + } + guard currentIndex != targetIndex else { return order } + var result = order + let moved = result.remove(at: currentIndex) + let insertionIndex = currentIndex < targetIndex ? targetIndex - 1 : targetIndex + result.insert(moved, at: max(0, min(insertionIndex, result.count))) + return result + } + + /// Mirrors `reorderWorkspace(tabId:before:)` and its clamped index plan. + private func reordered( + _ movedWorkspaceID: MobileWorkspacePreview.ID, + before beforeWorkspaceID: MobileWorkspacePreview.ID, + in order: [MobileWorkspacePreview] + ) -> [MobileWorkspacePreview] { + guard let currentIndex = order.firstIndex(where: { $0.id == movedWorkspaceID }), + let beforeIndex = order.firstIndex(where: { $0.id == beforeWorkspaceID }) else { + return order + } + let targetIndex = currentIndex < beforeIndex ? beforeIndex - 1 : beforeIndex + return reordered(movedWorkspaceID, toIndex: targetIndex, in: order) + } + + private func reorderedToGroupEnd( + _ movedWorkspaceID: MobileWorkspacePreview.ID, + groupID: MobileWorkspaceGroupPreview.ID, + in order: [MobileWorkspacePreview] + ) -> [MobileWorkspacePreview] { + guard let lastMemberIndex = order.lastIndex(where: { + $0.id != movedWorkspaceID && validGroupID($0.groupID) == groupID + }) else { + return order + } + return reordered(movedWorkspaceID, toIndex: lastMemberIndex + 1, in: order) + } + + private func reordered( + _ movedWorkspaceID: MobileWorkspacePreview.ID, + toIndex targetIndex: Int, + in order: [MobileWorkspacePreview] + ) -> [MobileWorkspacePreview] { + guard let currentIndex = order.firstIndex(where: { $0.id == movedWorkspaceID }) else { + return order + } + let legalIndex = clampedWorkspaceIndex(order[currentIndex], targetIndex: targetIndex, in: order) + guard currentIndex != legalIndex else { return order } + var result = order + let moved = result.remove(at: currentIndex) + result.insert(moved, at: max(0, min(legalIndex, result.count))) + return result + } + + /// Mirrors `clampedReorderIndex`, including every row of a pinned group. + private func clampedWorkspaceIndex( + _ workspace: MobileWorkspacePreview, + targetIndex: Int, + in order: [MobileWorkspacePreview] + ) -> Int { + let clamped = max(0, min(targetIndex, max(0, order.count - 1))) + if let groupedIndex = clampedGroupedMemberIndex(workspace, targetIndex: clamped, in: order) { + return groupedIndex + } + let pinnedCount = order.prefix(while: { isGlobalPinnedRow($0) }).count + return workspace.isPinned + ? min(clamped, max(0, pinnedCount - 1)) + : max(clamped, pinnedCount) + } + + private func clampedGroupedMemberIndex( + _ workspace: MobileWorkspacePreview, + targetIndex: Int, + in order: [MobileWorkspacePreview] + ) -> Int? { + guard let groupID = validGroupID(workspace.groupID), + let group = groupsByID[groupID], + workspace.id != group.anchorWorkspaceID else { + return nil + } + let memberIndices = order.indices.filter { validGroupID(order[$0].groupID) == groupID } + guard let firstIndex = memberIndices.first, let lastIndex = memberIndices.last else { return nil } + let pinnedMemberCount = memberIndices.reduce(into: 0) { count, index in + let member = order[index] + if member.id != group.anchorWorkspaceID, member.isPinned { + count += 1 + } + } + let lowerBound = workspace.isPinned + ? min(firstIndex + 1, lastIndex) + : min(firstIndex + 1 + pinnedMemberCount, lastIndex) + let upperBound = workspace.isPinned + ? max(firstIndex + pinnedMemberCount, lowerBound) + : lastIndex + return min(max(targetIndex, lowerBound), upperBound) + } + + private func clampedTopLevelIndex( + _ movedWorkspaceID: MobileWorkspacePreview.ID, + targetIndex: Int, + topLevelIDs: [MobileWorkspacePreview.ID], + in order: [MobileWorkspacePreview] + ) -> Int { + let clamped = max(0, min(targetIndex, max(0, topLevelIDs.count - 1))) + let pinnedIDs = pinnedTopLevelIDs(topLevelIDs, in: order) + let pinnedCount = topLevelIDs.filter { pinnedIDs.contains($0) }.count + return pinnedIDs.contains(movedWorkspaceID) + ? min(clamped, max(0, pinnedCount - 1)) + : max(clamped, pinnedCount) + } + + private func isGlobalPinnedRow(_ workspace: MobileWorkspacePreview) -> Bool { + if let groupID = validGroupID(workspace.groupID), let group = groupsByID[groupID] { + return group.isPinned + } + return workspace.isPinned + } + + /// Mirrors `normalizeWorkspaceGroupContiguity` and anchor-first pin tiers. + private func normalized( + _ order: [MobileWorkspacePreview], + preservingTopLevelIDs preferredIDs: [MobileWorkspacePreview.ID]? = nil + ) -> [MobileWorkspacePreview] { + let knownGroupIDs = Set(groups.map(\.id)) + let validOrder = order.map { workspace in + var copy = workspace + if copy.groupID.map({ !knownGroupIDs.contains($0) }) ?? false { + copy.groupID = nil + } + return copy + } + let topLevelIDs = preferredIDs ?? topLevelIDs(validOrder) + let pinnedIDs = pinnedTopLevelIDs(topLevelIDs, in: validOrder) + let desiredIDs = topLevelIDs.filter { pinnedIDs.contains($0) } + + topLevelIDs.filter { !pinnedIDs.contains($0) } + return rebuilt(validOrder, desiredTopLevelIDs: desiredIDs) + } + + private func rebuilt( + _ order: [MobileWorkspacePreview], + desiredTopLevelIDs: [MobileWorkspacePreview.ID] + ) -> [MobileWorkspacePreview] { + let workspacesByID = Dictionary(order.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + let grouped = Dictionary(grouping: order.filter { validGroupID($0.groupID) != nil }) { + validGroupID($0.groupID)! + } + var emittedWorkspaceIDs = Set() + var emittedGroupIDs = Set() + var result: [MobileWorkspacePreview] = [] + for id in desiredTopLevelIDs + order.map(\.id) { + guard let workspace = workspacesByID[id] else { continue } + if let groupID = validGroupID(workspace.groupID), + let group = groupsByID[groupID] { + guard emittedGroupIDs.insert(groupID).inserted else { continue } + let members = anchorFirst(grouped[groupID] ?? [], anchorID: group.anchorWorkspaceID) + for member in members where emittedWorkspaceIDs.insert(member.id).inserted { + result.append(member) + } + } else if emittedWorkspaceIDs.insert(workspace.id).inserted { + var copy = workspace + copy.groupID = nil + result.append(copy) + } + } + return result + } + + private func topLevelIDs(_ order: [MobileWorkspacePreview]) -> [MobileWorkspacePreview.ID] { + var emittedGroupIDs = Set() + var result: [MobileWorkspacePreview.ID] = [] + for workspace in order { + if let groupID = validGroupID(workspace.groupID), let group = groupsByID[groupID] { + if emittedGroupIDs.insert(groupID).inserted { + result.append(group.anchorWorkspaceID) + } + } else { + result.append(workspace.id) + } + } + return result + } + + private func pinnedTopLevelIDs( + _ ids: [MobileWorkspacePreview.ID], + in order: [MobileWorkspacePreview] + ) -> Set { + let workspacesByID = Dictionary(order.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + return Set(ids.filter { id in + groupsByAnchorID[id]?.isPinned ?? (workspacesByID[id]?.isPinned == true) + }) + } + + private func normalizedTopLevelBeforeID( + _ beforeWorkspaceID: MobileWorkspacePreview.ID?, + in order: [MobileWorkspacePreview] + ) -> MobileWorkspacePreview.ID? { + guard let beforeWorkspaceID, + let workspace = order.first(where: { $0.id == beforeWorkspaceID }), + let groupID = validGroupID(workspace.groupID), + let group = groupsByID[groupID] else { + return beforeWorkspaceID + } + return group.anchorWorkspaceID + } + + private func anchorFirst( + _ members: [MobileWorkspacePreview], + anchorID: MobileWorkspacePreview.ID + ) -> [MobileWorkspacePreview] { + guard let anchor = members.first(where: { $0.id == anchorID }) else { return members } + let remaining = members.filter { $0.id != anchorID } + return [anchor] + remaining.filter(\.isPinned) + remaining.filter { !$0.isPinned } + } + + private func validGroupID( + _ groupID: MobileWorkspaceGroupPreview.ID? + ) -> MobileWorkspaceGroupPreview.ID? { + guard let groupID, groupsByID[groupID] != nil else { return nil } + return groupID + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceListItemMoveEdgeTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceListItemMoveEdgeTests.swift new file mode 100644 index 000000000000..eeb53f90c581 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceListItemMoveEdgeTests.swift @@ -0,0 +1,112 @@ +import Foundation +import Testing + +@testable import CmuxMobileShellModel + +@Suite struct MobileWorkspaceListItemMoveEdgeTests { + private func workspace(_ id: String, group: String? = nil) -> MobileWorkspacePreview { + MobileWorkspacePreview( + id: .init(rawValue: id), + name: id, + groupID: group.map { .init(rawValue: $0) }, + terminals: [] + ) + } + + private func group(_ id: String, anchor: String) -> MobileWorkspaceGroupPreview { + MobileWorkspaceGroupPreview( + id: .init(rawValue: id), + name: id, + isCollapsed: false, + isPinned: false, + anchorWorkspaceID: .init(rawValue: anchor) + ) + } + + @Test func groupHeaderNoOpMoveIsRejected() { + let workspaces = [workspace("anchor", group: "g"), workspace("member", group: "g")] + let groups = [group("g", anchor: "anchor")] + let intent = MobileWorkspaceListItem.items(workspaces: workspaces, groups: groups).moveIntent( + workspaces: workspaces, + groups: groups, + sourceOffsets: IndexSet(integer: 0), + destination: 2 + ) + #expect(intent == nil) + } + + @Test func groupHeaderMovesAsOneBlock() throws { + let workspaces = [ + workspace("anchor", group: "g"), + workspace("member", group: "g"), + workspace("tail"), + ] + let groups = [group("g", anchor: "anchor")] + let items = MobileWorkspaceListItem.items(workspaces: workspaces, groups: groups) + let intent = try #require(items.moveIntent( + workspaces: workspaces, + groups: groups, + sourceOffsets: IndexSet(integer: 0), + destination: 4 + )) + #expect(intent == MobileWorkspaceMoveIntent( + groupID: nil, + beforeWorkspaceID: nil, + movesGroup: true + )) + let moved = workspaces.applyingWorkspaceMoveIntent( + intent, + movedWorkspaceID: "anchor", + groups: groups + ) + #expect(moved.map(\.id) == ["tail", "anchor", "member"]) + #expect(moved.suffix(2).allSatisfy { $0.groupID == "g" }) + } + + @Test func workspaceNoOpMoveIsRejected() { + let workspaces = [workspace("a"), workspace("b"), workspace("c")] + let items = workspaces.map { MobileWorkspaceListItem.workspace($0, indented: false) } + let intent = items.moveIntent( + workspaces: workspaces, + groups: [], + sourceOffsets: IndexSet(integer: 1), + destination: 2 + ) + #expect(intent == nil) + } + + @Test func identityDropAtGroupBoundaryIsRejected() { + let workspaces = [ + workspace("anchor", group: "g"), + workspace("member", group: "g"), + workspace("dragged"), + ] + let groups = [group("g", anchor: "anchor")] + let items = MobileWorkspaceListItem.items(workspaces: workspaces, groups: groups) + // [header, member, footer, dragged]: returning dragged to its gap is a no-op. + let intent = items.moveIntent( + workspaces: workspaces, + groups: groups, + sourceOffsets: IndexSet(integer: 3), + destination: 4 + ) + #expect(intent == nil) + } + + @Test func groupEndSlotCannotBeDragged() { + let workspaces = [ + workspace("anchor", group: "g"), + workspace("member", group: "g"), + workspace("dragged"), + ] + let groups = [group("g", anchor: "anchor")] + let items = MobileWorkspaceListItem.items(workspaces: workspaces, groups: groups) + let intent = items.moveIntent( + workspaces: workspaces, + groups: groups, + sourceOffsets: IndexSet(integer: 2), + destination: 0 + ) + #expect(intent == nil) + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceListItemTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceListItemTests.swift index 8c6f26a3031b..0b9f97c9922d 100644 --- a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceListItemTests.swift +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceListItemTests.swift @@ -34,10 +34,6 @@ import Testing ) } - private func footer(_ groupID: String) -> MobileWorkspaceListItem { - .groupFooter(.init(rawValue: groupID)) - } - @Test func ungroupedWorkspacesRenderFlatInOrder() { let items = MobileWorkspaceListItem.items( workspaces: [workspace("a"), workspace("b")], @@ -57,7 +53,7 @@ import Testing #expect(items == [ .groupHeader(group("g", anchor: "a"), hasUnread: false), .workspace(workspace("b", group: "g"), indented: true), - footer("g"), + .groupFooter("g"), ]) } @@ -83,7 +79,7 @@ import Testing .workspace(workspace("top"), indented: false), .groupHeader(group("g", anchor: "anchor"), hasUnread: false), .workspace(workspace("member", group: "g"), indented: true), - footer("g"), + .groupFooter("g"), .workspace(workspace("bottom"), indented: false), ]) } @@ -96,31 +92,20 @@ import Testing #expect(items == [.workspace(workspace("a", group: "missing"), indented: false)]) } - @Test func anchorOnlyGroupRendersHeaderAndFooter() { + @Test func anchorOnlyGroupRendersOnlyHeader() { let items = MobileWorkspaceListItem.items( workspaces: [workspace("a", group: "g")], groups: [group("g", anchor: "a")] ) - #expect(items == [ - .groupHeader(group("g", anchor: "a"), hasUnread: false), - footer("g"), - ]) + #expect(items == [.groupHeader(group("g", anchor: "a"), hasUnread: false)]) } - @Test func expandedGroupAlwaysEndsWithFooter() { + @Test func populatedExpandedGroupEndsWithItsDropSlot() { let items = MobileWorkspaceListItem.items( workspaces: [workspace("a", group: "g"), workspace("b", group: "g")], groups: [group("g", anchor: "a")] ) - #expect(items.last == footer("g")) - } - - @Test func collapsedGroupsDoNotRenderFooter() { - let items = MobileWorkspaceListItem.items( - workspaces: [workspace("a", group: "g"), workspace("b", group: "g")], - groups: [group("g", anchor: "a", collapsed: true)] - ) - #expect(!items.contains(footer("g"))) + #expect(items.last == .groupFooter("g")) } @Test func collapsedGroupHeaderCarriesMemberUnread() { @@ -156,7 +141,7 @@ import Testing #expect(items == [ .groupHeader(group("g", anchor: "a"), hasUnread: false), .workspace(workspace("b", group: "g", unread: true), indented: true), - footer("g"), + .groupFooter("g"), ]) } @@ -171,7 +156,7 @@ import Testing #expect(items == [ .groupHeader(group("g", anchor: "a"), hasUnread: true), .workspace(workspace("b", group: "g"), indented: true), - footer("g"), + .groupFooter("g"), ]) } @@ -249,7 +234,27 @@ import Testing #expect(intent == MobileWorkspaceMoveIntent(groupID: nil, beforeWorkspaceID: "anchor")) } - @Test func listMoveIntentGapAboveFooterAppendsInsideGroup() { + @Test func externalWorkspaceAtMixedGroupBoundaryStaysRoot() { + let workspaces = [ + workspace("anchor", group: "g"), + workspace("first", group: "g"), + workspace("second", group: "g"), + workspace("between"), + workspace("dragged"), + workspace("tail"), + ] + let groups = [group("g", anchor: "anchor")] + let items = MobileWorkspaceListItem.items(workspaces: workspaces, groups: groups) + let intent = items.moveIntent( + workspaces: workspaces, + groups: groups, + sourceOffsets: IndexSet(integer: 5), + destination: 4 + ) + #expect(intent == MobileWorkspaceMoveIntent(groupID: nil, beforeWorkspaceID: "between")) + } + + @Test func externalWorkspaceBeforeGroupEndSlotJoinsGroup() { let workspaces = [ workspace("anchor", group: "g"), workspace("first", group: "g"), @@ -269,6 +274,57 @@ import Testing #expect(intent == MobileWorkspaceMoveIntent(groupID: "g", beforeWorkspaceID: "between")) } + @Test func currentGroupMemberAtMixedBoundaryStaysAtGroupEnd() { + let workspaces = [ + workspace("anchor", group: "g"), + workspace("first", group: "g"), + workspace("last", group: "g"), + workspace("root-a"), + workspace("root-b"), + ] + let groups = [group("g", anchor: "anchor")] + let items = MobileWorkspaceListItem.items(workspaces: workspaces, groups: groups) + let intent = items.moveIntent( + workspaces: workspaces, + groups: groups, + sourceOffsets: IndexSet(integer: 1), + destination: 3 + ) + #expect(intent == MobileWorkspaceMoveIntent(groupID: "g", beforeWorkspaceID: "root-a")) + } + + @Test func groupedWorkspaceLeavesOnlyBelowNextRootRow() { + let workspaces = [ + workspace("anchor", group: "g"), + workspace("first", group: "g"), + workspace("last", group: "g"), + workspace("root-a"), + workspace("root-b"), + ] + let groups = [group("g", anchor: "anchor")] + let items = MobileWorkspaceListItem.items(workspaces: workspaces, groups: groups) + let intent = items.moveIntent( + workspaces: workspaces, + groups: groups, + sourceOffsets: IndexSet(integer: 1), + destination: 5 + ) + #expect(intent == MobileWorkspaceMoveIntent(groupID: nil, beforeWorkspaceID: "root-b")) + } + + @Test func belowExpandedAnchorOnlyHeaderJoinsGroup() { + let workspaces = [workspace("anchor", group: "g"), workspace("root"), workspace("dragged")] + let groups = [group("g", anchor: "anchor")] + let items = MobileWorkspaceListItem.items(workspaces: workspaces, groups: groups) + let intent = items.moveIntent( + workspaces: workspaces, + groups: groups, + sourceOffsets: IndexSet(integer: 2), + destination: 1 + ) + #expect(intent == MobileWorkspaceMoveIntent(groupID: "g", beforeWorkspaceID: "root")) + } + @Test func workspacesApplyingGroupIntentPlacesMovedWorkspaceInsideGroup() { let workspaces = [ workspace("anchor", group: "g"), @@ -276,9 +332,11 @@ import Testing workspace("root"), workspace("dragged"), ] + let groups = [group("g", anchor: "anchor")] let moved = workspaces.applyingWorkspaceMoveIntent( MobileWorkspaceMoveIntent(groupID: "g", beforeWorkspaceID: "root"), - movedWorkspaceID: "dragged" + movedWorkspaceID: "dragged", + groups: groups ) #expect(moved.map(\.id.rawValue) == ["anchor", "member", "dragged", "root"]) #expect(moved.first(where: { $0.id == "dragged" })?.groupID == "g") @@ -291,15 +349,17 @@ import Testing workspace("root"), workspace("dragged", group: "g"), ] + let groups = [group("g", anchor: "anchor")] let moved = workspaces.applyingWorkspaceMoveIntent( MobileWorkspaceMoveIntent(groupID: nil, beforeWorkspaceID: "root"), - movedWorkspaceID: "dragged" + movedWorkspaceID: "dragged", + groups: groups ) #expect(moved.map(\.id.rawValue) == ["anchor", "member", "dragged", "root"]) #expect(moved.first(where: { $0.id == "dragged" })?.groupID == nil) } - @Test func listMoveIntentGapBelowFooterUngroupsBetweenGroupAndRootRow() { + @Test func externalWorkspaceAtGroupEndGapRemainsRoot() { let workspaces = [ workspace("anchor", group: "g"), workspace("member", group: "g"), @@ -317,7 +377,7 @@ import Testing #expect(intent == MobileWorkspaceMoveIntent(groupID: nil, beforeWorkspaceID: "root")) } - @Test func listMoveIntentGapBelowFooterBeforeFollowingHeaderStaysTopLevel() { + @Test func mixedGapBeforeFollowingHeaderStaysTopLevelForExternalWorkspace() { let workspaces = [ workspace("a-anchor", group: "a"), workspace("a-member", group: "a"), @@ -387,85 +447,18 @@ import Testing #expect(intent == MobileWorkspaceMoveIntent(groupID: nil, beforeWorkspaceID: nil)) } - @Test func listMoveIntentRejectsGroupHeaderNoOpMove() { - let workspaces = [workspace("anchor", group: "g"), workspace("member", group: "g")] - let groups = [group("g", anchor: "anchor")] - let items = MobileWorkspaceListItem.items(workspaces: workspaces, groups: groups) - let intent = items.moveIntent( - workspaces: workspaces, - groups: groups, - sourceOffsets: IndexSet(integer: 0), - destination: 3 - ) - #expect(intent == nil) - } - - @Test func listMoveIntentMovesGroupHeaderAsGroupBlock() throws { - let workspaces = [ - workspace("anchor", group: "g"), - workspace("member", group: "g"), - workspace("tail"), - ] - let groups = [group("g", anchor: "anchor")] - let items = MobileWorkspaceListItem.items(workspaces: workspaces, groups: groups) - let intent = try #require(items.moveIntent( - workspaces: workspaces, - groups: groups, - sourceOffsets: IndexSet(integer: 0), - destination: 4 - )) - #expect(intent == MobileWorkspaceMoveIntent( - groupID: nil, - beforeWorkspaceID: nil, - movesGroup: true - )) - let moved = workspaces.applyingWorkspaceMoveIntent( - intent, - movedWorkspaceID: "anchor" - ) - #expect(moved.map(\.id) == ["tail", "anchor", "member"]) - #expect(moved.suffix(2).allSatisfy { $0.groupID == "g" }) - } - - @Test func listMoveIntentRejectsFooterRowMove() { - let workspaces = [workspace("anchor", group: "g"), workspace("member", group: "g"), workspace("tail")] - let groups = [group("g", anchor: "anchor")] - let items = MobileWorkspaceListItem.items(workspaces: workspaces, groups: groups) - let intent = items.moveIntent( - workspaces: workspaces, - groups: groups, - sourceOffsets: IndexSet(integer: 2), - destination: 4 - ) - #expect(intent == nil) - } - - @Test func listMoveIntentRejectsNoOpMove() { - let workspaces = [workspace("a"), workspace("b"), workspace("c")] - let items = workspaces.map { MobileWorkspaceListItem.workspace($0, indented: false) } - let intent = items.moveIntent( - workspaces: workspaces, - groups: [], - sourceOffsets: IndexSet(integer: 1), - destination: 2 - ) - #expect(intent == nil) - } - - @Test func listMoveIntentRejectsIdentityDropAtGroupBoundaryWithFooter() { - let workspaces = [ - workspace("anchor", group: "g"), - workspace("member", group: "g"), - workspace("dragged"), - ] - let groups = [group("g", anchor: "anchor")] - let items = MobileWorkspaceListItem.items(workspaces: workspaces, groups: groups) - let intent = items.moveIntent( - workspaces: workspaces, - groups: groups, - sourceOffsets: IndexSet(integer: 3), - destination: 4 + @Test func collapsedGroupEmitsNoEndOfGroupSlot() { + let items = MobileWorkspaceListItem.items( + workspaces: [ + workspace("anchor", group: "g"), + workspace("member", group: "g"), + workspace("tail"), + ], + groups: [group("g", anchor: "anchor", collapsed: true)] ) - #expect(intent == nil) + #expect(items == [ + .groupHeader(group("g", anchor: "anchor", collapsed: true), hasUnread: false), + .workspace(workspace("tail"), indented: false), + ]) } } diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceMovePolicyParityTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceMovePolicyParityTests.swift new file mode 100644 index 000000000000..af331f5b4ac0 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceMovePolicyParityTests.swift @@ -0,0 +1,196 @@ +import Foundation +import Testing + +@testable import CmuxMobileShellModel + +@Suite struct MobileWorkspaceMovePolicyParityTests { + private func workspace( + _ id: String, + group: String? = nil, + pinned: Bool = false + ) -> MobileWorkspacePreview { + MobileWorkspacePreview( + id: .init(rawValue: id), + name: id, + isPinned: pinned, + groupID: group.map { .init(rawValue: $0) }, + terminals: [] + ) + } + + private func group( + _ id: String, + anchor: String, + collapsed: Bool = false, + pinned: Bool = false + ) -> MobileWorkspaceGroupPreview { + MobileWorkspaceGroupPreview( + id: .init(rawValue: id), + name: id, + isCollapsed: collapsed, + isPinned: pinned, + anchorWorkspaceID: .init(rawValue: anchor) + ) + } + + @Test func stackedAnchorOnlyGroupsRenderAsHeadersWithoutSyntheticRows() { + let workspaces = [ + workspace("a", group: "a"), + workspace("b", group: "b"), + workspace("c", group: "c"), + ] + let groups = [ + group("a", anchor: "a"), + group("b", anchor: "b"), + group("c", anchor: "c"), + ] + #expect(MobileWorkspaceListItem.items(workspaces: workspaces, groups: groups) == [ + .groupHeader(groups[0], hasUnread: false), + .groupHeader(groups[1], hasUnread: false), + .groupHeader(groups[2], hasUnread: false), + ]) + } + + @Test func appliedIntentsMatchIndependentHostSimulatorAcrossFixtures() { + let fixtures: [([MobileWorkspacePreview], [MobileWorkspaceGroupPreview])] = [ + ( + [ + workspace("pa", group: "p"), + workspace("pm", group: "p"), + workspace("root-p", pinned: true), + workspace("root-p2", pinned: true), + workspace("ua", group: "u"), + workspace("up", group: "u", pinned: true), + workspace("um", group: "u"), + workspace("root"), + ], + [group("p", anchor: "pa", pinned: true), group("u", anchor: "ua")] + ), + ( + [ + workspace("a", group: "g"), + workspace("b", group: "g", pinned: true), + workspace("root"), + workspace("c", group: "h"), + workspace("d", group: "h"), + ], + [group("g", anchor: "a"), group("h", anchor: "c", collapsed: true)] + ), + ( + [workspace("solo", group: "s"), workspace("pinned", pinned: true), workspace("root")], + [group("s", anchor: "solo")] + ), + ( + [ + workspace("a", group: "a"), + workspace("b", group: "b"), + workspace("c", group: "c"), + workspace("root"), + ], + [ + group("a", anchor: "a"), + group("b", anchor: "b"), + group("c", anchor: "c"), + ] + ), + ] + for (workspaces, groups) in fixtures { + let rendered = MobileWorkspaceListItem.items(workspaces: workspaces, groups: groups) + for sourceIndex in rendered.indices { + for destination in 0...rendered.count { + guard let move = rendered.moveIntent( + workspaces: workspaces, + groups: groups, + sourceOffsets: IndexSet(integer: sourceIndex), + destination: destination + ), let movedWorkspaceID = movedWorkspaceID(for: rendered[sourceIndex]) else { + continue + } + let optimistic = workspaces.applyingWorkspaceMoveIntent( + move, + movedWorkspaceID: movedWorkspaceID, + groups: groups + ) + assertGroupBlocksAreContiguousAndAnchorFirst(optimistic, groups: groups) + assertPinnedTiersHold(optimistic, groups: groups) + let simulatedHost = MobileWorkspaceHostOrderSimulator( + workspaces: workspaces, + groups: groups + ).applying(move, movedWorkspaceID: movedWorkspaceID) + #expect(optimistic.map(\.id) == simulatedHost.map(\.id)) + #expect(optimistic.map(\.groupID) == simulatedHost.map(\.groupID)) + } + } + } + } + + private func movedWorkspaceID( + for item: MobileWorkspaceListItem + ) -> MobileWorkspacePreview.ID? { + switch item { + case .workspace(let workspace, _): + workspace.id + case .groupHeader(let group, _): + group.anchorWorkspaceID + case .groupFooter: + nil + } + } + + private func assertGroupBlocksAreContiguousAndAnchorFirst( + _ workspaces: [MobileWorkspacePreview], + groups: [MobileWorkspaceGroupPreview], + sourceLocation: SourceLocation = #_sourceLocation + ) { + for group in groups { + let indices = workspaces.indices.filter { workspaces[$0].groupID == group.id } + guard !indices.isEmpty else { continue } + #expect(indices == Array(indices.first!...indices.last!), sourceLocation: sourceLocation) + #expect(workspaces[indices[0]].id == group.anchorWorkspaceID, sourceLocation: sourceLocation) + } + } + + private func assertPinnedTiersHold( + _ workspaces: [MobileWorkspacePreview], + groups: [MobileWorkspaceGroupPreview], + sourceLocation: SourceLocation = #_sourceLocation + ) { + let groupsByID = Dictionary(groups.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + var seenUnpinnedTopLevel = false + var emittedGroups = Set() + for workspace in workspaces { + if let groupID = workspace.groupID, let group = groupsByID[groupID] { + if emittedGroups.insert(groupID).inserted { + if group.isPinned { + #expect(!seenUnpinnedTopLevel, sourceLocation: sourceLocation) + } else { + seenUnpinnedTopLevel = true + } + assertGroupMemberPinnedTier(workspaces, group: group, sourceLocation: sourceLocation) + } + } else if workspace.isPinned { + #expect(!seenUnpinnedTopLevel, sourceLocation: sourceLocation) + } else { + seenUnpinnedTopLevel = true + } + } + } + + private func assertGroupMemberPinnedTier( + _ workspaces: [MobileWorkspacePreview], + group: MobileWorkspaceGroupPreview, + sourceLocation: SourceLocation = #_sourceLocation + ) { + let members = workspaces.filter { $0.groupID == group.id } + guard let anchor = members.first else { return } + #expect(anchor.id == group.anchorWorkspaceID, sourceLocation: sourceLocation) + var seenUnpinned = false + for member in members.dropFirst() { + if member.isPinned { + #expect(!seenUnpinned, sourceLocation: sourceLocation) + } else { + seenUnpinned = true + } + } + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceMovePolicyTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceMovePolicyTests.swift new file mode 100644 index 000000000000..3cd4f360e30e --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceMovePolicyTests.swift @@ -0,0 +1,401 @@ +import Foundation +import Testing + +@testable import CmuxMobileShellModel + +@Suite struct MobileWorkspaceMovePolicyTests { + private func workspace( + _ id: String, + group: String? = nil, + pinned: Bool = false + ) -> MobileWorkspacePreview { + MobileWorkspacePreview( + id: .init(rawValue: id), + name: id, + isPinned: pinned, + groupID: group.map { .init(rawValue: $0) }, + terminals: [] + ) + } + + private func group( + _ id: String, + anchor: String, + collapsed: Bool = false, + pinned: Bool = false + ) -> MobileWorkspaceGroupPreview { + MobileWorkspaceGroupPreview( + id: .init(rawValue: id), + name: id, + isCollapsed: collapsed, + isPinned: pinned, + anchorWorkspaceID: .init(rawValue: anchor) + ) + } + + private func items( + _ workspaces: [MobileWorkspacePreview], + _ groups: [MobileWorkspaceGroupPreview] + ) -> [MobileWorkspaceListItem] { + MobileWorkspaceListItem.items(workspaces: workspaces, groups: groups) + } + + private func intent( + workspaces: [MobileWorkspacePreview], + groups: [MobileWorkspaceGroupPreview], + sourceID: String, + destination: Int + ) -> MobileWorkspaceMoveIntent? { + let rendered = items(workspaces, groups) + guard let sourceIndex = rendered.firstIndex(where: { $0.id == sourceID }) else { + Issue.record("Missing source row \(sourceID)") + return nil + } + return rendered.moveIntent( + workspaces: workspaces, + groups: groups, + sourceOffsets: IndexSet(integer: sourceIndex), + destination: destination + ) + } + + private func movedIDs( + _ workspaces: [MobileWorkspacePreview], + _ moveIntent: MobileWorkspaceMoveIntent, + movedWorkspaceID: MobileWorkspacePreview.ID, + groups: [MobileWorkspaceGroupPreview] + ) -> [String] { + workspaces.applyingWorkspaceMoveIntent( + moveIntent, + movedWorkspaceID: movedWorkspaceID, + groups: groups + ).map(\.id.rawValue) + } + + @Test func groupHeaderDroppedMidAnotherGroupNormalizesToWholeGroupBoundary() throws { + let workspaces = [ + workspace("b-anchor", group: "b"), + workspace("b-one", group: "b"), + workspace("b-two", group: "b"), + workspace("g-anchor", group: "g"), + workspace("g-member", group: "g"), + workspace("tail"), + ] + let groups = [group("b", anchor: "b-anchor"), group("g", anchor: "g-anchor")] + let move = try #require(intent( + workspaces: workspaces, + groups: groups, + sourceID: "group.g", + destination: 2 + )) + #expect(move == MobileWorkspaceMoveIntent( + groupID: nil, + beforeWorkspaceID: "b-anchor", + movesGroup: true + )) + #expect(movedIDs(workspaces, move, movedWorkspaceID: "g-anchor", groups: groups) == [ + "g-anchor", "g-member", "b-anchor", "b-one", "b-two", "tail", + ]) + } + + @Test func groupHeaderDroppedOnOwnMemberIsNoOp() { + let workspaces = [workspace("anchor", group: "g"), workspace("member", group: "g"), workspace("tail")] + let groups = [group("g", anchor: "anchor")] + #expect(intent(workspaces: workspaces, groups: groups, sourceID: "group.g", destination: 2) == nil) + } + + @Test func groupHeaderCanMoveToTopBottomAndBetweenRootRows() throws { + let workspaces = [ + workspace("root-a"), + workspace("root-b"), + workspace("anchor", group: "g"), + workspace("member", group: "g"), + ] + let groups = [group("g", anchor: "anchor")] + let toTop = try #require(intent(workspaces: workspaces, groups: groups, sourceID: "group.g", destination: 0)) + #expect(toTop.beforeWorkspaceID == "root-a") + let betweenRoots = try #require(intent(workspaces: workspaces, groups: groups, sourceID: "group.g", destination: 1)) + #expect(betweenRoots.beforeWorkspaceID == "root-b") + + let bottomWorkspaces = [ + workspace("anchor", group: "g"), + workspace("member", group: "g"), + workspace("root-a"), + workspace("root-b"), + ] + let toBottom = try #require(intent( + workspaces: bottomWorkspaces, + groups: groups, + sourceID: "group.g", + destination: items(bottomWorkspaces, groups).count + )) + #expect(toBottom.beforeWorkspaceID == nil) + } + + @Test func groupHeaderCanMoveAboveAndBelowExpandedOrCollapsedHeaders() throws { + let expanded = [ + workspace("a-anchor", group: "a"), + workspace("a-member", group: "a"), + workspace("b-anchor", group: "b"), + workspace("b-member", group: "b"), + ] + let expandedGroups = [group("a", anchor: "a-anchor"), group("b", anchor: "b-anchor")] + let aboveExpanded = try #require(intent( + workspaces: expanded, + groups: expandedGroups, + sourceID: "group.b", + destination: 0 + )) + #expect(aboveExpanded.beforeWorkspaceID == "a-anchor") + let collapsed = [ + workspace("a-anchor", group: "a"), + workspace("a-member", group: "a"), + workspace("b-anchor", group: "b"), + workspace("b-member", group: "b"), + workspace("c-anchor", group: "c"), + workspace("c-member", group: "c"), + ] + let collapsedGroups = [ + group("a", anchor: "a-anchor", collapsed: true), + group("b", anchor: "b-anchor", collapsed: true), + group("c", anchor: "c-anchor", collapsed: true), + ] + let belowCollapsed = try #require(intent( + workspaces: collapsed, + groups: collapsedGroups, + sourceID: "group.c", + destination: 1 + )) + #expect(belowCollapsed.beforeWorkspaceID == "b-anchor") + } + + @Test func workspaceGroupSlotsMapHeaderMemberBoundaryAndCollapsedRules() throws { + let workspaces = [ + workspace("anchor", group: "g"), + workspace("first", group: "g"), + workspace("second", group: "g"), + workspace("tail"), + workspace("dragged"), + ] + let groups = [group("g", anchor: "anchor")] + let belowHeader = try #require(intent(workspaces: workspaces, groups: groups, sourceID: "workspace.dragged", destination: 1)) + #expect(belowHeader == MobileWorkspaceMoveIntent(groupID: "g", beforeWorkspaceID: "first")) + let midGroup = try #require(intent(workspaces: workspaces, groups: groups, sourceID: "workspace.dragged", destination: 2)) + #expect(midGroup == MobileWorkspaceMoveIntent(groupID: "g", beforeWorkspaceID: "second")) + let beforeEndSlot = try #require(intent(workspaces: workspaces, groups: groups, sourceID: "workspace.dragged", destination: 3)) + #expect(beforeEndSlot == MobileWorkspaceMoveIntent(groupID: "g", beforeWorkspaceID: "tail")) + let afterEndSlot = try #require(intent(workspaces: workspaces, groups: groups, sourceID: "workspace.dragged", destination: 4)) + #expect(afterEndSlot == MobileWorkspaceMoveIntent(groupID: nil, beforeWorkspaceID: "tail")) + let aboveHeader = try #require(intent(workspaces: workspaces, groups: groups, sourceID: "workspace.dragged", destination: 0)) + #expect(aboveHeader == MobileWorkspaceMoveIntent(groupID: nil, beforeWorkspaceID: "anchor")) + + let collapsedGroups = [group("g", anchor: "anchor", collapsed: true)] + let belowCollapsedHeader = try #require(intent( + workspaces: workspaces, + groups: collapsedGroups, + sourceID: "workspace.dragged", + destination: 1 + )) + #expect(belowCollapsedHeader == MobileWorkspaceMoveIntent(groupID: nil, beforeWorkspaceID: "tail")) + } + + @Test func expandedAnchorOnlyGroupAcceptsBelowHeaderSlot() throws { + let workspaces = [workspace("anchor", group: "g"), workspace("tail"), workspace("dragged")] + let groups = [group("g", anchor: "anchor")] + let aboveHeader = try #require(intent(workspaces: workspaces, groups: groups, sourceID: "workspace.dragged", destination: 0)) + #expect(aboveHeader == MobileWorkspaceMoveIntent(groupID: nil, beforeWorkspaceID: "anchor")) + let belowHeader = try #require(intent(workspaces: workspaces, groups: groups, sourceID: "workspace.dragged", destination: 1)) + #expect(belowHeader == MobileWorkspaceMoveIntent(groupID: "g", beforeWorkspaceID: "tail")) + } + + @Test func movingLastNonAnchorMemberOutLeavesAnchorOnlyGroup() throws { + let workspaces = [workspace("anchor", group: "g"), workspace("member", group: "g"), workspace("root")] + let groups = [group("g", anchor: "anchor")] + let move = try #require(intent(workspaces: workspaces, groups: groups, sourceID: "workspace.member", destination: 4)) + #expect(move == MobileWorkspaceMoveIntent(groupID: nil, beforeWorkspaceID: nil)) + let moved = workspaces.applyingWorkspaceMoveIntent(move, movedWorkspaceID: "member", groups: groups) + #expect(moved.map(\.id.rawValue) == ["anchor", "root", "member"]) + #expect(moved.first(where: { $0.id == "anchor" })?.groupID == "g") + #expect(moved.first(where: { $0.id == "member" })?.groupID == nil) + } + + @Test func lastMemberBoundaryKeepsMembershipUntilBothNeighborsAreOutside() throws { + let workspaces = [ + workspace("anchor", group: "g"), + workspace("first", group: "g"), + workspace("last", group: "g"), + workspace("root-a"), + workspace("root-b"), + ] + let groups = [group("g", anchor: "anchor")] + let groupEnd = try #require(intent( + workspaces: workspaces, + groups: groups, + sourceID: "workspace.first", + destination: 3 + )) + #expect(groupEnd == MobileWorkspaceMoveIntent(groupID: "g", beforeWorkspaceID: "root-a")) + #expect(movedIDs(workspaces, groupEnd, movedWorkspaceID: "first", groups: groups) == [ + "anchor", "last", "first", "root-a", "root-b", + ]) + + let directExit = try #require(intent( + workspaces: workspaces, + groups: groups, + sourceID: "workspace.first", + destination: 4 + )) + #expect(directExit == MobileWorkspaceMoveIntent(groupID: nil, beforeWorkspaceID: "root-a")) + + let outside = try #require(intent( + workspaces: workspaces, + groups: groups, + sourceID: "workspace.first", + destination: 5 + )) + #expect(outside == MobileWorkspaceMoveIntent(groupID: nil, beforeWorkspaceID: "root-b")) + } + + @Test func pinnedWorkspaceAndGroupTopLevelMovesClampToTheirTier() throws { + let workspaces = [ + workspace("p1", pinned: true), + workspace("p2", pinned: true), + workspace("u1"), + workspace("u2"), + ] + let flatItems = workspaces.map { MobileWorkspaceListItem.workspace($0, indented: false) } + let pinnedDown = try #require(flatItems.moveIntent( + workspaces: workspaces, + groups: [], + sourceOffsets: IndexSet(integer: 0), + destination: 4 + )) + #expect(pinnedDown == MobileWorkspaceMoveIntent(groupID: nil, beforeWorkspaceID: "u1")) + #expect(movedIDs(workspaces, pinnedDown, movedWorkspaceID: "p1", groups: []) == ["p2", "p1", "u1", "u2"]) + + let unpinnedUp = try #require(flatItems.moveIntent( + workspaces: workspaces, + groups: [], + sourceOffsets: IndexSet(integer: 3), + destination: 0 + )) + #expect(unpinnedUp == MobileWorkspaceMoveIntent(groupID: nil, beforeWorkspaceID: "u1")) + + let grouped = [ + workspace("ga", group: "g"), + workspace("gm", group: "g"), + workspace("pa", group: "p"), + workspace("pm", group: "p"), + workspace("ha", group: "h"), + workspace("hm", group: "h"), + workspace("root"), + ] + let groups = [ + group("g", anchor: "ga", pinned: true), + group("p", anchor: "pa", pinned: true), + group("h", anchor: "ha"), + ] + let groupMove = try #require(intent( + workspaces: grouped, + groups: groups, + sourceID: "group.g", + destination: items(grouped, groups).count + )) + #expect(groupMove.beforeWorkspaceID == "ha") + } + + @Test func pinnedWorkspaceAfterLeadingPinnedGroupUsesWholePinnedTier() throws { + let workspaces = [ + workspace("pa", group: "g"), + workspace("pm", group: "g"), + workspace("q1", pinned: true), + workspace("q2", pinned: true), + workspace("r"), + ] + let groups = [group("g", anchor: "pa", pinned: true)] + let belowQ2 = try #require(intent( + workspaces: workspaces, + groups: groups, + sourceID: "workspace.q1", + destination: 5 + )) + #expect(belowQ2.beforeWorkspaceID == "r") + #expect(movedIDs(workspaces, belowQ2, movedWorkspaceID: "q1", groups: groups) == [ + "pa", "pm", "q2", "q1", "r", + ]) + + let belowUnpinnedRange = try #require(intent( + workspaces: workspaces, + groups: groups, + sourceID: "workspace.q1", + destination: items(workspaces, groups).count + )) + #expect(belowUnpinnedRange.beforeWorkspaceID == "r") + #expect(movedIDs(workspaces, belowUnpinnedRange, movedWorkspaceID: "q1", groups: groups) == [ + "pa", "pm", "q2", "q1", "r", + ]) + } + + @Test func inGroupPinnedMembersClampBelowAnchorAndAboveUnpinnedMembers() throws { + let workspaces = [ + workspace("anchor", group: "g"), + workspace("pinned-a", group: "g", pinned: true), + workspace("pinned-b", group: "g", pinned: true), + workspace("plain", group: "g"), + workspace("tail"), + ] + let groups = [group("g", anchor: "anchor")] + let pinnedDown = try #require(intent( + workspaces: workspaces, + groups: groups, + sourceID: "workspace.pinned-a", + destination: 4 + )) + #expect(pinnedDown == MobileWorkspaceMoveIntent(groupID: "g", beforeWorkspaceID: "plain")) + let plainUp = intent(workspaces: workspaces, groups: groups, sourceID: "workspace.plain", destination: 1) + #expect(plainUp == nil) + } + + @Test func staleGroupAndUnknownBeforeWorkspaceNeverEmitDoomedIntent() { + let workspaces = [workspace("stale", group: "missing"), workspace("root")] + let rendered = items(workspaces, []) + let move = rendered.moveIntent( + workspaces: workspaces, + groups: [], + sourceOffsets: IndexSet(integer: 0), + destination: 2 + ) + #expect(move == MobileWorkspaceMoveIntent(groupID: nil, beforeWorkspaceID: nil)) + + let rejected = MobileWorkspaceMovePolicy(workspaces: workspaces, groups: []).normalizedIntent( + MobileWorkspaceMoveIntent(groupID: nil, beforeWorkspaceID: "missing"), + movedWorkspaceID: "root" + ) + #expect(rejected == nil) + } + + @Test func multiItemDestinationClampingAndIdentityMovesAreRejected() { + let workspaces = [workspace("a"), workspace("b"), workspace("c")] + let rendered = workspaces.map { MobileWorkspaceListItem.workspace($0, indented: false) } + #expect(rendered.moveIntent( + workspaces: workspaces, + groups: [], + sourceOffsets: IndexSet([0, 1]), + destination: 3 + ) == nil) + #expect(rendered.moveIntent( + workspaces: workspaces, + groups: [], + sourceOffsets: IndexSet(integer: 1), + destination: 2 + ) == nil) + let clamped = rendered.moveIntent( + workspaces: workspaces, + groups: [], + sourceOffsets: IndexSet(integer: 2), + destination: -10 + ) + #expect(clamped == MobileWorkspaceMoveIntent(groupID: nil, beforeWorkspaceID: "a")) + } + +} diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceOptimisticOrderTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceOptimisticOrderTests.swift new file mode 100644 index 000000000000..119cecbb34ae --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceOptimisticOrderTests.swift @@ -0,0 +1,179 @@ +import Testing + +@testable import CmuxMobileShellModel + +@Suite struct MobileWorkspaceOptimisticOrderTests { + private func workspace( + _ id: String, + name: String? = nil, + groupID: String? = nil, + unread: Bool = false, + pinned: Bool = false + ) -> MobileWorkspacePreview { + MobileWorkspacePreview( + id: .init(rawValue: id), + name: name ?? id, + isPinned: pinned, + groupID: groupID.map(MobileWorkspaceGroupPreview.ID.init(rawValue:)), + hasUnread: unread, + terminals: [] + ) + } + + private func group(_ id: String, collapsed: Bool, pinned: Bool = false) -> MobileWorkspaceGroupPreview { + MobileWorkspaceGroupPreview( + id: .init(rawValue: id), + name: id, + isCollapsed: collapsed, + isPinned: pinned, + anchorWorkspaceID: "anchor" + ) + } + + private func snapshots(_ ids: [String]) -> [MobileWorkspacePreview] { + ids.map { workspace($0) } + } + + private func order(_ ids: [String]) -> MobileWorkspaceOptimisticOrder { + MobileWorkspaceOptimisticOrder(workspaces: snapshots(ids)) + } + + @Test func threeMoveChainKeepsEveryIntermediateUntilDisplayedOrderArrives() { + let a0 = snapshots(["a", "b", "c", "d"]) + let o1 = snapshots(["b", "a", "c", "d"]) + let o2 = snapshots(["b", "c", "a", "d"]) + let o3 = snapshots(["b", "c", "d", "a"]) + var state = MobileWorkspaceOptimisticOrderReconciler( + optimisticOrder: MobileWorkspaceOptimisticOrder(workspaces: o3), + pendingBases: [a0, o1, o2].map { MobileWorkspaceOptimisticOrder(workspaces: $0) } + ) + + state = state.reconciling(authoritative: o1) + #expect(state.optimisticOrder == MobileWorkspaceOptimisticOrder(workspaces: o3)) + #expect(state.pendingBases == [order(["b", "a", "c", "d"]), order(["b", "c", "a", "d"])]) + + state = state.reconciling(authoritative: o2) + #expect(state.optimisticOrder == MobileWorkspaceOptimisticOrder(workspaces: o3)) + #expect(state.pendingBases == [order(["b", "c", "a", "d"])]) + + state = state.reconciling(authoritative: o3) + #expect(state.optimisticOrder == nil) + #expect(state.pendingBases.isEmpty) + } + + @Test func outOfOrderOlderSnapshotSupersedesAfterLaterIntermediate() { + let o1 = snapshots(["b", "a", "c", "d"]) + let o2 = snapshots(["b", "c", "a", "d"]) + var state = MobileWorkspaceOptimisticOrderReconciler( + optimisticOrder: order(["b", "c", "d", "a"]), + pendingBases: [order(["a", "b", "c", "d"]), order(["b", "a", "c", "d"]), order(["b", "c", "a", "d"])] + ) + + state = state.reconciling(authoritative: o2) + #expect(state.pendingBases == [order(["b", "c", "a", "d"])]) + state = state.reconciling(authoritative: o1) + #expect(state.optimisticOrder == nil) + #expect(state.pendingBases.isEmpty) + } + + @Test func failureMidChainRollsBackAndClearsEveryPendingBase() { + let state = MobileWorkspaceOptimisticOrderReconciler( + optimisticOrder: order(["b", "c", "a"]), + pendingBases: [order(["a", "b", "c"]), order(["b", "a", "c"])] + ).reconciling(authoritative: snapshots(["b", "a", "c"]), moveDidFail: true) + + #expect(state.optimisticOrder == nil) + #expect(state.pendingBases.isEmpty) + } + + @Test func liveContentFlowsThroughWhileOrderAndMembershipAreHeld() { + let optimistic = MobileWorkspaceOptimisticOrder(workspaces: [ + workspace("b"), workspace("a", groupID: "g"), + ]) + let authoritative = [ + workspace("a", name: "Updated title", unread: true), workspace("b"), + ] + let displayed = optimistic.materializedWorkspaces(from: authoritative) + + #expect(displayed.map(\.id.rawValue) == ["b", "a"]) + #expect(displayed[1].name == "Updated title") + #expect(displayed[1].hasUnread) + #expect(displayed[1].groupID == "g") + } + + @Test func collapseChangeRematerializesGroupedItemsWhileOrderIsHeld() { + let authoritative = [ + workspace("anchor", groupID: "g"), + workspace("member", groupID: "g", unread: true), + ] + let displayed = MobileWorkspaceOptimisticOrder(workspaces: authoritative) + .materializedWorkspaces(from: authoritative) + let expanded = MobileWorkspaceListItem.items(workspaces: displayed, groups: [group("g", collapsed: false)]) + let collapsed = MobileWorkspaceListItem.items(workspaces: displayed, groups: [group("g", collapsed: true)]) + + #expect(expanded.map(\.id) == ["group.g", "workspace.member", "groupFooter.g"]) + #expect(collapsed.map(\.id) == ["group.g"]) + guard case .groupHeader(_, hasUnread: true) = collapsed[0] else { + Issue.record("Collapsed header did not rematerialize its aggregate unread state") + return + } + } + + @Test func deletedWorkspaceDropsOutOfHeldOrder() { + let base = order(["a", "b", "c", "d"]) + let optimistic = order(["b", "a", "d", "c"]) + let authoritative = snapshots(["a", "c", "d"]) + let state = MobileWorkspaceOptimisticOrderReconciler( + optimisticOrder: optimistic, + pendingBases: [base] + ).reconciling(authoritative: authoritative) + let displayed = state.optimisticOrder?.materializedWorkspaces(from: authoritative) + + #expect(state.optimisticOrder == optimistic) + #expect(displayed?.map(\.id.rawValue) == ["a", "d", "c"]) + } + + @Test func newWorkspaceAppearsBesideItsAuthoritativeNeighbor() { + let base = order(["a", "b", "c"]) + let optimistic = order(["b", "a", "c"]) + let authoritative = snapshots(["a", "new", "b", "c"]) + let state = MobileWorkspaceOptimisticOrderReconciler( + optimisticOrder: optimistic, + pendingBases: [base] + ).reconciling(authoritative: authoritative) + let displayed = state.optimisticOrder?.materializedWorkspaces(from: authoritative) + + #expect(state.optimisticOrder == optimistic) + #expect(displayed?.map(\.id.rawValue) == ["b", "a", "new", "c"]) + } + @Test func workspacePinChangeSupersedesHeldOptimism() { + let predicted = [workspace("b"), workspace("a")] + let base = [workspace("a"), workspace("b")] + let state = MobileWorkspaceOptimisticOrderReconciler( + optimisticOrder: MobileWorkspaceOptimisticOrder(workspaces: predicted), + pendingBases: [MobileWorkspaceOptimisticOrder(workspaces: base)] + ) + // The Mac pinned "a" while the move was in flight and clamped the + // reorder to a no-op; the same-order snapshot must supersede instead + // of matching the stale base. + let pinnedSnapshot = [workspace("a", pinned: true), workspace("b")] + let next = state.reconciling(authoritative: pinnedSnapshot) + #expect(next.optimisticOrder == nil) + #expect(next.pendingBases.isEmpty) + } + + @Test func groupPinChangeSupersedesHeldOptimism() { + let members = [workspace("anchor", groupID: "g"), workspace("m", groupID: "g"), workspace("r")] + let predicted = [workspace("anchor", groupID: "g"), workspace("r"), workspace("m", groupID: "g")] + let unpinnedGroup = [group("g", collapsed: false)] + let state = MobileWorkspaceOptimisticOrderReconciler( + optimisticOrder: MobileWorkspaceOptimisticOrder(workspaces: predicted, groups: unpinnedGroup), + pendingBases: [MobileWorkspaceOptimisticOrder(workspaces: members, groups: unpinnedGroup)] + ) + let pinnedGroup = [group("g", collapsed: false, pinned: true)] + let next = state.reconciling(authoritative: members, groups: pinnedGroup) + #expect(next.optimisticOrder == nil) + #expect(next.pendingBases.isEmpty) + } + +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceGroupFooterRow.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceGroupFooterRow.swift index 49ad33ea88cc..a664c03483f7 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceGroupFooterRow.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceGroupFooterRow.swift @@ -5,18 +5,12 @@ struct WorkspaceGroupFooterRow: View { let groupName: String? var body: some View { - ZStack(alignment: .leading) { - Rectangle() - .fill(Color.secondary.opacity(0.22)) - .frame(width: 1) - .padding(.leading, 7) - - Rectangle() - .fill(Color.secondary.opacity(0.42)) - .frame(width: 14, height: 1) - .padding(.leading, 7) - } - .frame(height: 12) + // Invisible spacer row: the end-of-group drop slot (before it = into + // the group, after it = root) and accessibility element, drawing + // nothing. 16pt keeps the slot draggable without a visible gap; only + // populated expanded groups emit it, so header stacks stay slot-free. + Color.clear + .frame(height: 16) .contentShape(Rectangle()) .accessibilityElement() .accessibilityLabel(footerAccessibilityLabel) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView+Actions.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView+Actions.swift index fa46db03e8b0..77d5882e0b8b 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView+Actions.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView+Actions.swift @@ -4,11 +4,28 @@ import SwiftUI extension WorkspaceListView { var newWorkspaceButton: some View { - Button { - guard canCreateWorkspaceForMacSelection else { return } - createWorkspace() + Menu { + Button { + guard canCreateWorkspaceForMacSelection else { return } + createWorkspace() + } label: { + Label(L10n.string("mobile.workspace.new", defaultValue: "New Workspace"), systemImage: "plus") + } + .accessibilityIdentifier("MobileNewWorkspaceMenuItem") + if let createWorkspaceGroup { + Button { + guard canCreateWorkspaceForMacSelection else { return } + createWorkspaceGroup() + } label: { + Label(L10n.string("mobile.workspaceGroup.new", defaultValue: "New Workspace Group"), systemImage: "folder.badge.plus") + } + .accessibilityIdentifier("MobileNewWorkspaceGroupMenuItem") + } } label: { Image(systemName: "plus") + } primaryAction: { + guard canCreateWorkspaceForMacSelection else { return } + createWorkspace() } .disabled(!canCreateWorkspaceForMacSelection) .accessibilityLabel(L10n.string("mobile.workspace.new", defaultValue: "New Workspace")) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView+DragDrop.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView+DragDrop.swift index 832c3b53a59b..f75d79a9537d 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView+DragDrop.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView+DragDrop.swift @@ -3,13 +3,16 @@ import Foundation import SwiftUI extension WorkspaceListView { - var hasPendingWorkspaceMove: Bool { - isWorkspaceMovePending - } + /// Pipelining bound: with reorder enabled during pending moves, a slow or + /// offline Mac must not let the send chain grow without limit, and every + /// queued move currently costs a full workspace refresh on reply. Normal + /// round-trips resolve between drags, so this only bites when the host is + /// genuinely unresponsive. + static let maxPipelinedWorkspaceMoves = 3 var enablesWorkspaceReorder: Bool { moveWorkspace != nil - && !hasPendingWorkspaceMove + && pendingWorkspaceMoveCount < Self.maxPipelinedWorkspaceMoves && canRenderGroupsForSelection && trimmedQuery.isEmpty && filter.readState == .all @@ -36,20 +39,33 @@ extension WorkspaceListView { && canRenderGroupsForSelection } - func syncOptimisticWorkspaceOrder() { - guard optimisticFlatWorkspaces != nil - || optimisticGroupedItems != nil - || optimisticGroupedWorkspaces != nil else { - return + func syncOptimisticWorkspaceOrder(moveDidFail: Bool = false) { + let hadPendingOptimism = optimisticFlatState.optimisticOrder != nil + || optimisticGroupedState.optimisticOrder != nil + optimisticFlatState = optimisticFlatState.reconciling( + authoritative: filteredWorkspaces, + moveDidFail: moveDidFail + ) + optimisticGroupedState = optimisticGroupedState.reconciling( + authoritative: groupedWorkspaces, + groups: groups, + moveDidFail: moveDidFail + ) + let cleared = optimisticFlatState.optimisticOrder == nil + && optimisticGroupedState.optimisticOrder == nil + // A supersede (or failure) invalidates every queued dependent: their + // intents were computed against predictions the host has overruled. + // Bumping the epoch makes not-yet-sent moves abort, and detaching the + // tail lets fresh drags start a clean chain. + if hadPendingOptimism, cleared, pendingWorkspaceMoveCount > 0 { + workspaceMoveEpoch &+= 1 + pendingWorkspaceMoveTask = nil } - optimisticFlatWorkspaces = nil - optimisticGroupedItems = nil - optimisticGroupedWorkspaces = nil } func moveFlatRows(from sourceOffsets: IndexSet, to destination: Int) { guard enablesWorkspaceReorder else { return } - let sourceWorkspaces = optimisticFlatWorkspaces ?? filteredWorkspaces + let sourceWorkspaces = displayedFlatWorkspaces let items = sourceWorkspaces.map { MobileWorkspaceListItem.workspace($0, indented: false) } guard let intent = items.moveIntent( workspaces: sourceWorkspaces, @@ -61,23 +77,51 @@ extension WorkspaceListView { } var movedWorkspaces = sourceWorkspaces movedWorkspaces.move(fromOffsets: sourceOffsets, toOffset: destination) - optimisticFlatWorkspaces = movedWorkspaces + optimisticFlatState = MobileWorkspaceOptimisticOrderReconciler( + optimisticOrder: MobileWorkspaceOptimisticOrder(workspaces: movedWorkspaces), + pendingBases: optimisticFlatState.pendingBases + + [MobileWorkspaceOptimisticOrder(workspaces: sourceWorkspaces)] + ) guard let sourceIndex = sourceOffsets.first, case .workspace(let workspace, _) = items[sourceIndex] else { return } - isWorkspaceMovePending = true - Task { @MainActor in - await moveWorkspace?(workspace.id, intent.groupID, intent.beforeWorkspaceID, intent.movesGroup) - isWorkspaceMovePending = false - syncOptimisticWorkspaceOrder() + pendingWorkspaceMoveCount += 1 + let previousMove = pendingWorkspaceMoveTask + let epoch = workspaceMoveEpoch + pendingWorkspaceMoveTask = Task { @MainActor in + // Chain on the prior send: the intent was computed against the + // prior move's predicted order, so the host must apply them in + // the same order or the snapshot diverges and drops optimism. + // A rejected predecessor rolled the list back, and a supersede + // bumped the epoch; either way this stale intent must not be + // sent. The handlers reset the chain tail, so drags started + // afterwards never see these branches. + if let previousMove, await previousMove.value == false { + pendingWorkspaceMoveCount -= 1 + return false + } + guard epoch == workspaceMoveEpoch else { + pendingWorkspaceMoveCount -= 1 + return false + } + let accepted = await moveWorkspace?(workspace.id, intent.groupID, intent.beforeWorkspaceID, intent.movesGroup) ?? false + pendingWorkspaceMoveCount -= 1 + if !accepted { + syncOptimisticWorkspaceOrder(moveDidFail: true) + // Detach the chain so the completed failed task cannot poison + // future drags; queued dependents still hold their captured + // reference and drain by aborting above. + pendingWorkspaceMoveTask = nil + } + return accepted } } func moveGroupedRows(from sourceOffsets: IndexSet, to destination: Int) { guard enablesWorkspaceReorder else { return } - let sourceItems = optimisticGroupedItems ?? groupedListItems - let sourceWorkspaces = optimisticGroupedWorkspaces ?? groupedWorkspaces + let sourceItems = displayedGroupedListItems + let sourceWorkspaces = displayedGroupedWorkspaces guard let intent = sourceItems.moveIntent( workspaces: sourceWorkspaces, groups: groups, @@ -100,15 +144,35 @@ extension WorkspaceListView { } let movedWorkspaces = sourceWorkspaces.applyingWorkspaceMoveIntent( intent, - movedWorkspaceID: movedWorkspaceID + movedWorkspaceID: movedWorkspaceID, + groups: groups + ) + optimisticGroupedState = MobileWorkspaceOptimisticOrderReconciler( + optimisticOrder: MobileWorkspaceOptimisticOrder(workspaces: movedWorkspaces, groups: groups), + pendingBases: optimisticGroupedState.pendingBases + + [MobileWorkspaceOptimisticOrder(workspaces: sourceWorkspaces, groups: groups)] ) - optimisticGroupedWorkspaces = movedWorkspaces - optimisticGroupedItems = MobileWorkspaceListItem.items(workspaces: movedWorkspaces, groups: groups) - isWorkspaceMovePending = true - Task { @MainActor in - await moveWorkspace?(movedWorkspaceID, intent.groupID, intent.beforeWorkspaceID, intent.movesGroup) - isWorkspaceMovePending = false - syncOptimisticWorkspaceOrder() + pendingWorkspaceMoveCount += 1 + let previousMove = pendingWorkspaceMoveTask + let epoch = workspaceMoveEpoch + pendingWorkspaceMoveTask = Task { @MainActor in + // Same ordering, predecessor-failure, and epoch contract as + // moveFlatRows. + if let previousMove, await previousMove.value == false { + pendingWorkspaceMoveCount -= 1 + return false + } + guard epoch == workspaceMoveEpoch else { + pendingWorkspaceMoveCount -= 1 + return false + } + let accepted = await moveWorkspace?(movedWorkspaceID, intent.groupID, intent.beforeWorkspaceID, intent.movesGroup) ?? false + pendingWorkspaceMoveCount -= 1 + if !accepted { + syncOptimisticWorkspaceOrder(moveDidFail: true) + pendingWorkspaceMoveTask = nil + } + return accepted } } } @@ -149,9 +213,9 @@ struct WorkspaceListStableOrderKey: Equatable { macDeviceID = nil isPinned = group.isPinned isGroupCollapsed = group.isCollapsed - case .groupFooter(let groupID): + case .groupFooter(let footerGroupID): workspaceID = nil - self.groupID = groupID + groupID = footerGroupID windowID = nil macDeviceID = nil isPinned = nil diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift index 12514da2a811..baa9b2ea2d78 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift @@ -31,6 +31,7 @@ struct WorkspaceListView: View { let selectWorkspace: (MobileWorkspacePreview.ID) -> Void let createWorkspace: () -> Void var createWorkspaceInGroup: ((MobileWorkspaceGroupPreview.ID) -> Void)? = nil + var createWorkspaceGroup: (() -> Void)? = nil var canCreateWorkspace = true /// Which Mac's workspaces the list is focused on. Owned by the shell so /// every create-workspace entrypoint shares the same selected-Mac gate. @@ -79,7 +80,7 @@ struct WorkspaceListView: View { _ id: MobileWorkspacePreview.ID, _ groupID: MobileWorkspaceGroupPreview.ID?, _ beforeWorkspaceID: MobileWorkspacePreview.ID?, _ movesGroup: Bool - ) async -> Void)? = nil + ) async -> Bool)? = nil /// Optional: rename a workspace group on the Mac. var renameWorkspaceGroup: ((MobileWorkspaceGroupPreview.ID, String) -> Void)? = nil /// Optional: pin or unpin a workspace group on the Mac. @@ -118,10 +119,18 @@ struct WorkspaceListView: View { /// Stored at list scope so reusable rows do not own transient presentation /// state while `List` is recycling swipe-action rows. @State var workspacePendingCloseID: MobileWorkspacePreview.ID? - @State var optimisticFlatWorkspaces: [MobileWorkspacePreview]? - @State var optimisticGroupedItems: [MobileWorkspaceListItem]? - @State var optimisticGroupedWorkspaces: [MobileWorkspacePreview]? - @State var isWorkspaceMovePending = false + @State var optimisticFlatState = MobileWorkspaceOptimisticOrderReconciler() + @State var optimisticGroupedState = MobileWorkspaceOptimisticOrderReconciler() + /// In-flight move RPC count plus the tail of the send chain. Moves stay + /// enabled while pending (disabling mid-gesture cancels the reorder + /// interaction), so rapid drags can pipeline; sends are chained so the Mac + /// applies them in UI order and the authoritative snapshot converges on + /// the predicted optimistic order instead of racing it. + @State var pendingWorkspaceMoveCount = 0 + @State var pendingWorkspaceMoveTask: Task? + /// Bumped when a supersede or failure invalidates the pending chain, so + /// queued moves computed against overruled predictions abort unsent. + @State var workspaceMoveEpoch: UInt64 = 0 var trimmedQuery: String { searchText.trimmingCharacters(in: .whitespacesAndNewlines) @@ -146,19 +155,9 @@ struct WorkspaceListView: View { macTitlePickerPendingSelection != nil } - /// Whether the list renders grouped sections. Groups are honored whenever the - /// Mac actually emitted group sections and the user is not searching. The - /// gate is the payload itself, not `toggleGroupCollapsed`: a Mac that emits - /// groups also handles collapse/expand, but the capability flag arrives via a - /// separate `mobile.host.status` call, and a slow or failed status fetch must - /// not flatten sections the list already has (it would only lose the chevron - /// action). A search flattens to a single matched, pinned-first list so - /// members can be found across groups; floating pinned members out of their - /// group is acceptable while filtering. An active filter-menu dimension - /// flattens the same way, for the same reason. A single-Mac picker scope - /// still renders groups only for the foreground Mac whose group metadata is - /// available here; "All Computers" and secondary computer selections flatten because - /// group ids are Mac-local. Non-iOS builds keep the pre-picker behavior. + /// Groups render from the payload while unfiltered and scoped to the + /// foreground Mac. Search, filters, and multi-Mac scopes flatten the list; + /// the independently fetched collapse capability does not gate rendering. var rendersGroupedSections: Bool { !groups.isEmpty && trimmedQuery.isEmpty @@ -173,10 +172,7 @@ struct WorkspaceListView: View { || workspace.terminals.contains { $0.name.localizedCaseInsensitiveContains(query) } } - /// Workspaces after the row filter (Unread) and search filtering, pinned - /// ones first (stable within each group so the Mac's order is otherwise - /// preserved). Used for the flat (ungrouped, filtering, or searching) - /// presentation. + /// Filtered workspaces for flat presentation, pinned first and otherwise stable. var filteredWorkspaces: [MobileWorkspacePreview] { let query = trimmedQuery let currentFilter = activeFilter @@ -194,9 +190,7 @@ struct WorkspaceListView: View { .map(\.element) } - /// Ordered drawable items for the grouped presentation. Preserves the Mac's - /// member order and contiguity (no pinned-first flattening, which would - /// scatter group members). + /// Grouped drawable items preserving the Mac's member order and contiguity. var groupedListItems: [MobileWorkspaceListItem] { MobileWorkspaceListItem.items(workspaces: groupedWorkspaces, groups: groups) } @@ -205,11 +199,17 @@ struct WorkspaceListView: View { } var displayedFlatWorkspaces: [MobileWorkspacePreview] { - optimisticFlatWorkspaces ?? filteredWorkspaces + optimisticFlatState.optimisticOrder? + .materializedWorkspaces(from: filteredWorkspaces) ?? filteredWorkspaces + } + + var displayedGroupedWorkspaces: [MobileWorkspacePreview] { + optimisticGroupedState.optimisticOrder? + .materializedWorkspaces(from: groupedWorkspaces) ?? groupedWorkspaces } var displayedGroupedListItems: [MobileWorkspaceListItem] { - optimisticGroupedItems ?? groupedListItems + MobileWorkspaceListItem.items(workspaces: displayedGroupedWorkspaces, groups: groups) } var groupedWorkspaces: [MobileWorkspacePreview] { @@ -288,6 +288,8 @@ struct WorkspaceListView: View { } } .listStyle(.plain) + // Let the invisible footer use its 16pt boundary height. Real rows are taller. + .environment(\.defaultMinListRowHeight, 16) .workspaceListRefreshable(refresh) .onChange(of: currentFilterMenuPresentMachineIDs) { _, present in // Drop machine filters whose Mac left the aggregated list (a secondary @@ -317,7 +319,7 @@ struct WorkspaceListView: View { filter.pruneMachinesForFilterMenu(visibleMacSelection: currentVisibleMacSelection) } .onChange(of: filteredWorkspaceOrderKey) { _, _ in - if optimisticFlatWorkspaces != nil { optimisticFlatWorkspaces = nil } + syncOptimisticWorkspaceOrder() } .onChange(of: groupedWorkspaceOrderKey) { _, _ in syncOptimisticWorkspaceOrder() @@ -539,6 +541,10 @@ struct WorkspaceListView: View { toggleCollapsed: toggleGroupCollapsed, unreadIndicatorLeftShift: unreadIndicatorLeftShift ) + // The list-wide minimum row height is lowered for the + // invisible end-of-group spacer; interactive rows keep the + // 44pt tap target (32 content + 6/6 insets) explicitly. + .frame(minHeight: 32) .moveDisabled(!(enablesReorder && anchorCapabilities.supportsMoveActions)) .listRowInsets(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12)) .listRowSeparator(.hidden) @@ -586,14 +592,6 @@ struct WorkspaceListView: View { ) : "" ) - .overlay(alignment: .leading) { - if indented { - Rectangle() - .fill(Color.secondary.opacity(0.22)) - .frame(width: 1) - .padding(.leading, 7) - } - } .listRowInsets(EdgeInsets(top: 4, leading: indented ? 32 : 12, bottom: 4, trailing: 12)) .listRowSeparator(.hidden) } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView+WorkspaceActionToast.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView+WorkspaceActionToast.swift index 7cf0131ac607..f910f6c0f74c 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView+WorkspaceActionToast.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView+WorkspaceActionToast.swift @@ -6,6 +6,7 @@ import SwiftUI enum WorkspaceActionToastAction { case createWorkspace case createWorkspaceInGroup + case createWorkspaceGroup case moveWorkspace case renameWorkspace case pinWorkspace @@ -59,6 +60,8 @@ extension WorkspaceShellView { return L10n.string("mobile.workspaceAction.failure.action.createWorkspace", defaultValue: "create workspace") case .createWorkspaceInGroup: return L10n.string("mobile.workspaceAction.failure.action.createWorkspaceInGroup", defaultValue: "create workspace in group") + case .createWorkspaceGroup: + return L10n.string("mobile.workspaceAction.failure.action.createWorkspaceGroup", defaultValue: "create workspace group") case .moveWorkspace: return L10n.string("mobile.workspaceAction.failure.action.moveWorkspace", defaultValue: "move workspace") case .renameWorkspace: diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView+WorkspaceActions.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView+WorkspaceActions.swift index 70e603b5d2f0..41506bb41072 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView+WorkspaceActions.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView+WorkspaceActions.swift @@ -58,7 +58,7 @@ extension WorkspaceShellView { _ groupID: MobileWorkspaceGroupPreview.ID?, _ beforeWorkspaceID: MobileWorkspacePreview.ID?, _ movesGroup: Bool - ) async -> Void)? { + ) async -> Bool)? { let store = store return { id, groupID, beforeWorkspaceID, movesGroup in let result = await store.moveWorkspace( @@ -70,6 +70,10 @@ extension WorkspaceShellView { await MainActor.run { handleWorkspaceActionResult(result, action: .moveWorkspace) } + if case .success = result { + return true + } + return false } } @@ -134,6 +138,16 @@ extension WorkspaceShellView { return { groupID in createWorkspaceIfConnected(inGroup: groupID) } } + var createWorkspaceGroupInCompactStackClosure: (() -> Void)? { + guard store.supportsWorkspaceGroupCreate else { return nil } + return { createWorkspaceGroupIfConnected() } + } + + var createWorkspaceGroupIfConnectedClosure: (() -> Void)? { + guard store.supportsWorkspaceGroupCreate else { return nil } + return { createWorkspaceGroupIfConnected() } + } + func createWorkspaceInCompactStack() { createWorkspaceInCompactStack(inGroup: nil) } @@ -180,6 +194,14 @@ extension WorkspaceShellView { } } + func createWorkspaceGroupIfConnected() { + guard canCreateWorkspaceForMacSelection else { return } + Task { @MainActor in + let result = await store.createWorkspaceGroup() + handleWorkspaceActionResult(result, action: .createWorkspaceGroup) + } + } + private func clearPendingCompactCreateNavigationIfSettled( existingWorkspaceIDs: Set ) { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift index b25334ad1ddb..87594abc085b 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift @@ -121,6 +121,7 @@ struct WorkspaceShellView: View { selectWorkspace: selectWorkspace, createWorkspace: createWorkspaceInCompactStack, createWorkspaceInGroup: createWorkspaceInGroupInCompactStackClosure, + createWorkspaceGroup: createWorkspaceGroupInCompactStackClosure, canCreateWorkspace: canCreateWorkspaceForMacSelection, macSelection: $macSelection, switchMac: { macDeviceID in @@ -224,6 +225,7 @@ struct WorkspaceShellView: View { selectWorkspace: selectWorkspace, createWorkspace: createWorkspaceIfConnected, createWorkspaceInGroup: createWorkspaceInGroupIfConnectedClosure, + createWorkspaceGroup: createWorkspaceGroupIfConnectedClosure, canCreateWorkspace: canCreateWorkspaceForMacSelection, macSelection: $macSelection, switchMac: { macDeviceID in diff --git a/Sources/Mobile/MobileHostService+Capabilities.swift b/Sources/Mobile/MobileHostService+Capabilities.swift index e6897d73d75d..b8148ea2fdb2 100644 --- a/Sources/Mobile/MobileHostService+Capabilities.swift +++ b/Sources/Mobile/MobileHostService+Capabilities.swift @@ -29,6 +29,7 @@ extension MobileHostService { "workspace.close.v1", "workspace.move.v1", "workspace.group_actions.v1", + "workspace.group_create.v1", "workspace.create_in_group.v1", "dogfood.v1", // The workspace list carries group sections (group_id per workspace + diff --git a/Sources/Mobile/MobileHostService.swift b/Sources/Mobile/MobileHostService.swift index 3e6783b89ea3..52f2fff30a71 100644 --- a/Sources/Mobile/MobileHostService.swift +++ b/Sources/Mobile/MobileHostService.swift @@ -1322,7 +1322,7 @@ final class MobileHostService { private func ticketAuthorizationResultIfNeeded(for request: MobileHostRPCRequest) -> MobileHostRPCResult? { let createsWorkspaceInGroup = request.method == "workspace.create" && request.params["group_id"] != nil && !(request.params["group_id"] is NSNull) - let requiresCurrentAttachTicket = request.method == "workspace.move" || request.method == "workspace.group.action" || createsWorkspaceInGroup + let requiresCurrentAttachTicket = request.method == "workspace.move" || request.method == "workspace.group.action" || request.method == "workspace.group.create" || createsWorkspaceInGroup guard let attachToken = request.auth?.attachToken?.trimmingCharacters(in: .whitespacesAndNewlines), !attachToken.isEmpty else { return requiresCurrentAttachTicket ? .failure(Self.scopedTicketError) : nil @@ -1400,7 +1400,7 @@ final class MobileHostService { ) case "workspace.action", "workspace.close": return ticketWorkspaceAuthorizationError(authorization: authorization, workspaceSelection: workspaceSelection.value) - case "workspace.group.action": + case "workspace.group.action", "workspace.group.create": return ticketMacScopedWorkspaceMutationAuthorizationError(authorization: authorization) case "workspace.group.collapse", "workspace.group.expand": // Display-only group state. Keyed by `group_id` (not a workspace or diff --git a/Sources/TerminalController+WorkspaceGroupAction.swift b/Sources/TerminalController+WorkspaceGroupAction.swift index d12a2f147bf7..7033979f221f 100644 --- a/Sources/TerminalController+WorkspaceGroupAction.swift +++ b/Sources/TerminalController+WorkspaceGroupAction.swift @@ -1,6 +1,34 @@ import Foundation extension TerminalController { + /// Mobile-gated workspace-group creation that mirrors mobile workspace.create. + func v2MobileWorkspaceGroupCreate(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "Workspace context is unavailable", data: nil) + } + let title = v2RawString(params, "title")?.trimmingCharacters(in: .whitespacesAndNewlines) + let name = title?.isEmpty == false ? title ?? "" : "" + + var mutationError: V2CallResult? + v2MainSync { + guard tabManager.createWorkspaceGroup( + name: name, + selectAnchor: false, + collapseSidebarSelection: false + ) != nil else { + mutationError = .err(code: "not_created", message: "Group was not created", data: nil) + return + } + } + if let mutationError { + return mutationError + } + + var listParams = params + listParams.removeValue(forKey: "title") + return v2MobileWorkspaceList(params: listParams, tabManager: tabManager) + } + /// Mobile-gated workspace-group mutations that mirror the desktop header menu. func v2MobileWorkspaceGroupAction(params: [String: Any]) -> V2CallResult { guard v2HasNonNullParam(params, "group_id"), let groupID = v2UUID(params, "group_id") else { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 9661e7600c14..337c229ad914 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -14026,8 +14026,8 @@ class TerminalController { result = v2MobileWorkspaceAction(params: request.params) case "workspace.move": result = v2MobileWorkspaceMove(params: request.params) - case "workspace.group.action": - result = v2MobileWorkspaceGroupAction(params: request.params) + case "workspace.group.action", "workspace.group.create": + result = request.method == "workspace.group.create" ? v2MobileWorkspaceGroupCreate(params: request.params) : v2MobileWorkspaceGroupAction(params: request.params) case let method where method.hasPrefix("mobile.chat."): result = await v2MobileChatDispatch(method: method, params: request.params) case "workspace.close": diff --git a/cmuxTests/MobileHostServiceSettingsTests.swift b/cmuxTests/MobileHostServiceSettingsTests.swift index 10a381d77cce..a6f749ef47a9 100644 --- a/cmuxTests/MobileHostServiceSettingsTests.swift +++ b/cmuxTests/MobileHostServiceSettingsTests.swift @@ -136,6 +136,7 @@ struct MobileHostMacScopedMutationAuthorizationTests { ("workspace.create", ["group_id": "group-main"]), ("workspace.move", ["workspace_id": "workspace-main", "before_workspace_id": "workspace-next"]), ("workspace.group.action", ["group_id": "group-main", "action": "rename"]), + ("workspace.group.create", ["title": "Ops"]), ] for (method, params) in cases { let request = MobileHostRPCRequest( @@ -160,6 +161,7 @@ struct MobileHostMacScopedMutationAuthorizationTests { ("workspace.create", ["group_id": "group-main"]), ("workspace.move", ["workspace_id": "workspace-main", "before_workspace_id": "workspace-next"]), ("workspace.group.action", ["group_id": "group-main", "action": "rename"]), + ("workspace.group.create", ["title": "Ops"]), ] for (method, params) in cases { let request = MobileHostRPCRequest( diff --git a/cmuxTests/WorkspaceGroupMoveToMenuStateTests.swift b/cmuxTests/WorkspaceGroupMoveToMenuStateTests.swift index ae7038c4cb5d..e10b446ba9d8 100644 --- a/cmuxTests/WorkspaceGroupMoveToMenuStateTests.swift +++ b/cmuxTests/WorkspaceGroupMoveToMenuStateTests.swift @@ -112,4 +112,40 @@ struct WorkspaceGroupMoveToMenuStateTests { #expect(manager.workspaceGroups.contains { $0.id == groupId }) #expect(manager.tabs.filter { $0.groupId == groupId }.count == originalIds.count + 1) } + + @Test func mobileWorkspaceGroupCreateUsesTrimmedTitleAndReturnsWorkspaceList() throws { + let manager = TabManager() + manager.addWorkspace(autoWelcomeIfNeeded: false) + let previousManager = TerminalController.shared.activeTabManagerForCallerNotification() + TerminalController.shared.setActiveTabManager(manager) + defer { TerminalController.shared.setActiveTabManager(previousManager) } + + let result = TerminalController.shared.v2MobileWorkspaceGroupCreate(params: ["title": " Ops "]) + + guard case .ok(let payload) = result, + let object = payload as? [String: Any], + let groups = object["groups"] as? [[String: Any]] else { + return #expect(Bool(false), "group create should return a workspace list payload") + } + #expect(manager.workspaceGroups.count == 1) + #expect(manager.workspaceGroups.first?.name == "Ops") + #expect(manager.tabs.contains { $0.id == manager.workspaceGroups.first?.anchorWorkspaceId }) + #expect(groups.first?["name"] as? String == "Ops") + } + + @Test func mobileWorkspaceGroupCreateTreatsBlankTitleAsAutoName() throws { + let manager = TabManager() + manager.addWorkspace(autoWelcomeIfNeeded: false) + let expectedName = String.localizedStringWithFormat(manager.localizedAutoGroupNameFormat, 1) + let previousManager = TerminalController.shared.activeTabManagerForCallerNotification() + TerminalController.shared.setActiveTabManager(manager) + defer { TerminalController.shared.setActiveTabManager(previousManager) } + + let result = TerminalController.shared.v2MobileWorkspaceGroupCreate(params: ["title": " "]) + + guard case .ok = result else { + return #expect(Bool(false), "blank title should create a default-named group") + } + #expect(manager.workspaceGroups.first?.name == expectedName) + } } diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 200ebe51c382..5ac447f041f3 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -8722,6 +8722,23 @@ } } }, + "mobile.workspaceGroup.new": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Workspace Group" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規ワークスペースグループ" + } + } + } + }, "mobile.workspaceGroup.newWorkspace": { "extractionState": "manual", "localizations": { @@ -9076,6 +9093,13 @@ "ja": { "stringUnit": { "state": "translated", "value": "グループ内にワークスペースを作成" } } } }, + "mobile.workspaceAction.failure.action.createWorkspaceGroup": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "create workspace group" } }, + "ja": { "stringUnit": { "state": "translated", "value": "ワークスペースグループを作成" } } + } + }, "mobile.workspaceAction.failure.action.moveWorkspace": { "extractionState": "manual", "localizations": {