diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift index e6e11ea6d839..cd34ec176c76 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift @@ -273,8 +273,11 @@ extension ControlCommandCoordinator { let afterID = uuid(params, "after_workspace_id") let dryRun = bool(params, "dry_run") ?? false - let targetCount = (index != nil ? 1 : 0) + (beforeID != nil ? 1 : 0) + (afterID != nil ? 1 : 0) - if targetCount != 1 { + // Count supplied selectors, not resolved identities. An unknown ref + // must neither look like a missing target nor hide a conflicting one. + let targetCount = ["index", "before_workspace_id", "after_workspace_id"] + .filter { hasNonNull(params, $0) }.count + if targetCount != 1 || (hasNonNull(params, "index") && index == nil) { return .err( code: "invalid_params", message: "Specify exactly one target: index, before_workspace_id, or after_workspace_id", @@ -282,14 +285,20 @@ extension ControlCommandCoordinator { ) } - let resolution = context?.controlReorderWorkspace( - routing: routingSelectors(params), - workspaceID: workspaceID, - toIndex: index, - beforeWorkspaceID: beforeID, - afterWorkspaceID: afterID, - dryRun: dryRun - ) ?? .notFound + let resolution: ControlWorkspaceReorderResolution + if (hasNonNull(params, "before_workspace_id") && beforeID == nil) + || (hasNonNull(params, "after_workspace_id") && afterID == nil) { + resolution = .notFound + } else { + resolution = context?.controlReorderWorkspace( + routing: routingSelectors(params), + workspaceID: workspaceID, + toIndex: index, + beforeWorkspaceID: beforeID, + afterWorkspaceID: afterID, + dryRun: dryRun + ) ?? .notFound + } switch resolution { case .notFound: return .err(code: "not_found", message: "Workspace not found", data: .object([ diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlWorkspaceReorderTargetTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlWorkspaceReorderTargetTests.swift new file mode 100644 index 000000000000..4d9bfb4b0d84 --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlWorkspaceReorderTargetTests.swift @@ -0,0 +1,67 @@ +import Foundation +import Testing +@testable import CmuxControlSocket + +@MainActor +struct ControlWorkspaceReorderTargetTests { + @Test(arguments: ["before_workspace_id", "after_workspace_id"]) + func unresolvedRelativeTargetReportsNotFound(key: String) { + let context = FakeWorkspaceControlCommandContext() + let coordinator = ControlCommandCoordinator(context: context) + let result = coordinator.handle(ControlRequest(id: .int(1), method: "workspace.reorder", params: [ + "workspace_id": .string(UUID().uuidString), + key: .string("workspace:999999"), + "dry_run": .bool(true) + ])) + guard case .err(let code, _, _) = result else { + Issue.record("An unknown relative target must fail") + return + } + #expect(code == "not_found") + #expect(context.reorderCall == nil) + } + + @Test(arguments: ["before_workspace_id", "after_workspace_id"]) + func unresolvedTargetStillConflictsWithIndex(key: String) { + let context = FakeWorkspaceControlCommandContext() + let coordinator = ControlCommandCoordinator(context: context) + let result = coordinator.handle(ControlRequest(id: .int(1), method: "workspace.reorder", params: [ + "workspace_id": .string(UUID().uuidString), + key: .string("workspace:999999"), + "index": .int(0) + ])) + guard case .err(let code, _, _) = result else { + Issue.record("Conflicting targets must fail") + return + } + #expect(code == "invalid_params") + #expect(context.reorderCall == nil) + } + + @Test(arguments: ["before_workspace_id", "after_workspace_id"], [true, false]) + func knownRelativeTargetReachesPlannerWithoutIndex(key: String, dryRun: Bool) throws { + let context = FakeWorkspaceControlCommandContext() + let coordinator = ControlCommandCoordinator(context: context) + let workspaceID = UUID() + let targetID = UUID() + let workspaceRef = coordinator.ensureRef(kind: .workspace, uuid: workspaceID) + let targetRef = coordinator.ensureRef(kind: .workspace, uuid: targetID) + context.reorderResolution = .resolved( + windowID: nil, + plan: ControlWorkspaceReorderPlanItem(workspaceID: workspaceID, fromIndex: 1, toIndex: 0) + ) + let result = coordinator.handle(ControlRequest(id: .int(1), method: "workspace.reorder", params: [ + "workspace_id": .string(workspaceRef), key: .string(targetRef), "dry_run": .bool(dryRun) + ])) + guard case .ok = result else { + Issue.record("A known relative target must succeed") + return + } + let call = try #require(context.reorderCall) + #expect(call.workspaceID == workspaceID) + #expect(call.index == nil) + #expect(call.before == (key == "before_workspace_id" ? targetID : nil)) + #expect(call.after == (key == "after_workspace_id" ? targetID : nil)) + #expect(call.dryRun == dryRun) + } +} diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeWorkspaceControlCommandContext.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeWorkspaceControlCommandContext.swift index 35daf637eafa..2694f4a9a1a3 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeWorkspaceControlCommandContext.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeWorkspaceControlCommandContext.swift @@ -12,6 +12,8 @@ final class FakeWorkspaceControlCommandContext: ControlCommandContext { var listResolution: ControlWorkspaceListResolution = .tabManagerUnavailable var currentResolution: ControlWorkspaceCurrentResolution = .tabManagerUnavailable var closeResolution: ControlWorkspaceCloseResolution = .tabManagerUnavailable + var reorderResolution: ControlWorkspaceReorderResolution = .notFound + var reorderCall: (workspaceID: UUID, index: Int?, before: UUID?, after: UUID?, dryRun: Bool)? var addWorkspaceToGroupResolution: ControlWorkspaceGroupAddResolution = .tabManagerUnavailable var addWorkspaceToGroupCall: ( groupID: UUID, @@ -144,6 +146,18 @@ final class FakeWorkspaceControlCommandContext: ControlCommandContext { return addWorkspaceToGroupResolution } + func controlReorderWorkspace( + routing: ControlRoutingSelectors, + workspaceID: UUID, + toIndex: Int?, + beforeWorkspaceID: UUID?, + afterWorkspaceID: UUID?, + dryRun: Bool + ) -> ControlWorkspaceReorderResolution { + reorderCall = (workspaceID, toIndex, beforeWorkspaceID, afterWorkspaceID, dryRun) + return reorderResolution + } + func controlWorkspaceRemoteTerminalSessionEnd( workspaceID: UUID, surfaceID: UUID, relayPort: Int?, terminalLifecycleID: UUID?, sessionID: String?,