diff --git a/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift index d55641835e69..f3eb3a9b24f4 100644 --- a/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift +++ b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift @@ -242,13 +242,19 @@ struct HomeThreadCollectionView: UIViewRepresentable { contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint ) -> UIContextMenuConfiguration? { - guard case let .thread(thread, _, _, isArchived, _) = item(at: indexPath) else { + guard case let .thread(thread, context, _, isArchived, _) = item(at: indexPath) else { return nil } return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in guard let self else { return nil } - return UIMenu(children: self.menuActions(for: thread, isArchived: isArchived)) + return UIMenu( + children: self.menuActions( + for: thread, + context: context, + isArchived: isArchived + ) + ) } } @@ -623,7 +629,11 @@ struct HomeThreadCollectionView: UIViewRepresentable { } } - private func menuActions(for thread: FeatureThread, isArchived: Bool) -> [UIMenuElement] { + private func menuActions( + for thread: FeatureThread, + context: HomeThreadRowContext, + isArchived: Bool + ) -> [UIMenuElement] { let rename = UIAction(title: "Rename", image: UIImage(systemName: "pencil")) { [weak self] _ in self?.parent.onRename(thread) } @@ -639,6 +649,27 @@ struct HomeThreadCollectionView: UIViewRepresentable { } ) } + let copyActions = ThreadCopyModel.menuActions( + for: thread, + context: context.copyContext + ) + if !copyActions.isEmpty { + titleActions.append( + UIMenu( + title: "Copy", + image: UIImage(systemName: "doc.on.doc"), + children: copyActions.map { action in + UIAction( + title: action.kind.title, + image: UIImage(systemName: action.kind.systemImage), + attributes: action.isAvailable ? [] : .disabled + ) { _ in + ThreadCopyClipboard.copy(action) + } + } + ) + ) + } var statusActions: [UIMenuElement] = [] if !isArchived { diff --git a/apps/swift-ios/Features/Workspace/ThreadCopyActions.swift b/apps/swift-ios/Features/Workspace/ThreadCopyActions.swift new file mode 100644 index 000000000000..083a28904c26 --- /dev/null +++ b/apps/swift-ios/Features/Workspace/ThreadCopyActions.swift @@ -0,0 +1,144 @@ +import Foundation +import UIKit + +enum ThreadCopyActionKind: Equatable, Sendable { + case path + case branch + case threadID + case project + case environment + case url + + var title: String { + switch self { + case .path: "Path" + case .branch: "Branch" + case .threadID: "Thread ID" + case .project: "Project" + case .environment: "Environment" + case .url: "URL" + } + } + + var systemImage: String { + switch self { + case .path: "folder" + case .branch: "arrow.triangle.branch" + case .threadID: "number" + case .project: "folder.badge.gearshape" + case .environment: "desktopcomputer" + case .url: "link" + } + } + + var copyAnnouncement: String { "\(title) copied" } +} + +struct ThreadCopyContext: Equatable, Sendable { + let projectName: String? + let projectWorkspaceRoot: String? + let environmentName: String? + let environmentID: String? +} + +struct ThreadCopyAction: Equatable, Sendable { + let kind: ThreadCopyActionKind + let value: String? + + var isAvailable: Bool { value != nil } + + var announcement: String { + isAvailable ? kind.copyAnnouncement : "\(kind.title) unavailable" + } +} + +enum ThreadCopyModel { + /// The long-press row menu mirrors the Electron thread menu. + static func menuActions( + for thread: FeatureThread, + context: ThreadCopyContext + ) -> [ThreadCopyAction] { + actions(for: thread, context: context).filter { action in + switch action.kind { + case .path, .branch, .threadID: + true + case .project, .environment, .url: + false + } + } + } + + static func actions( + for thread: FeatureThread, + context: ThreadCopyContext + ) -> [ThreadCopyAction] { + var actions: [ThreadCopyAction] = [] + + let path = nonBlank(thread.worktreePath) ?? nonBlank(context.projectWorkspaceRoot) + actions.append(ThreadCopyAction(kind: .path, value: path)) + if let branch = nonBlank(thread.branch) { + actions.append(ThreadCopyAction(kind: .branch, value: branch)) + } + if let threadID = nonBlank(thread.wireID) ?? nonBlank(thread.id) { + actions.append(ThreadCopyAction(kind: .threadID, value: threadID)) + } + if let project = nonBlank(context.projectName) { + actions.append(ThreadCopyAction(kind: .project, value: project)) + } + if let environment = nonBlank(context.environmentName) { + actions.append(ThreadCopyAction(kind: .environment, value: environment)) + } + + let environmentID = nonBlank(thread.environmentID) ?? nonBlank(context.environmentID) + if let url = threadURL(environmentID: environmentID, threadID: nonBlank(thread.wireID)) { + actions.append(ThreadCopyAction(kind: .url, value: url)) + } + + return actions + } + + private static func threadURL(environmentID: String?, threadID: String?) -> String? { + guard let environmentID, + let threadID, + let encodedEnvironmentID = pathSegment(environmentID), + let encodedThreadID = pathSegment(threadID) else { + return nil + } + + var components = URLComponents() + components.scheme = "https" + components.host = "app.t3.codes" + components.percentEncodedPath = "/\(encodedEnvironmentID)/\(encodedThreadID)" + return components.url?.absoluteString + } + + private static func pathSegment(_ value: String) -> String? { + let allowed = CharacterSet( + charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + ) + return value.addingPercentEncoding(withAllowedCharacters: allowed) + } + + /// Availability ignores surrounding whitespace, while the copied value remains byte-for-byte + /// identical to the value received from the server. + private static func nonBlank(_ value: String?) -> String? { + guard let value, + !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return nil + } + return value + } +} + +@MainActor +enum ThreadCopyClipboard { + static func copy(_ action: ThreadCopyAction) { + if let value = action.value { + UIPasteboard.general.string = value + } + UIAccessibility.post( + notification: .announcement, + argument: action.announcement + ) + } +} diff --git a/apps/swift-ios/Features/Workspace/WorkspaceView.swift b/apps/swift-ios/Features/Workspace/WorkspaceView.swift index 319f58df865b..a1c16038fcf4 100644 --- a/apps/swift-ios/Features/Workspace/WorkspaceView.swift +++ b/apps/swift-ios/Features/Workspace/WorkspaceView.swift @@ -840,6 +840,15 @@ struct HomeThreadRowContext: Equatable { connectionState: nil ) + var copyContext: ThreadCopyContext { + ThreadCopyContext( + projectName: projectWorkspaceRoot == nil ? nil : projectName, + projectWorkspaceRoot: projectWorkspaceRoot, + environmentName: environmentLabel, + environmentID: projectEnvironmentID + ) + } + var providerLooksTerminal: Bool { let normalized = [providerDriver, providerID, providerName] .joined(separator: " ") @@ -865,7 +874,11 @@ struct HomeThreadRowContext: Equatable { } return snapshot.threads.reduce(into: [String: HomeThreadRowContext]()) { result, thread in let project = projectByID[thread.projectID] - let environmentID = thread.environmentID ?? project?.environmentID + let normalizedThreadEnvironmentID = thread.environmentID? + .trimmingCharacters(in: .whitespacesAndNewlines) + let environmentID = normalizedThreadEnvironmentID?.isEmpty == false + ? normalizedThreadEnvironmentID + : project?.environmentID let environment = environmentID.flatMap { environmentByID[$0] } let environmentLabel = (environment?.name ?? thread.environmentName)? .trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift b/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift index 8901461c4f95..d6737d8a389e 100644 --- a/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift @@ -325,6 +325,56 @@ struct HomeThreadMetadataTests { #expect(context.projectName == "pingdotgg/t3code") } + @Test + func fallbackRowContextDoesNotOfferPlaceholderProjectCopy() { + let thread = FeatureThread( + id: "thread", + projectID: "missing-project", + title: "Unresolved project" + ) + + let actions = ThreadCopyModel.actions( + for: thread, + context: HomeThreadRowContext.fallback.copyContext + ) + + #expect(actions.contains { $0.kind == .project } == false) + } + + @Test + func rowContextFallsBackToProjectEnvironmentForBlankThreadEnvironment() throws { + let thread = FeatureThread( + id: "thread", + projectID: "project", + environmentID: " ", + title: "Blank environment" + ) + let snapshot = FeatureSnapshot( + environments: [ + FeatureEnvironment( + id: "device", + name: "Desk Mac", + endpoint: "http://device", + connectionState: .connected + ), + ], + projects: [ + FeatureProject( + id: "project", + environmentID: "device", + name: "t3code", + path: "/work/t3code" + ), + ], + threads: [thread] + ) + + let context = try #require(HomeThreadRowContext.index(snapshot: snapshot)[thread.id]) + + #expect(context.environmentLabel == "Desk Mac") + #expect(context.copyContext.environmentID == "device") + } + @Test func pullRequestIndicatorsUseTheCurrentThreadBranchAndPreserveTheirState() { let thread = FeatureThread( diff --git a/apps/swift-ios/Tests/FeatureTests/ThreadCopyActionsTests.swift b/apps/swift-ios/Tests/FeatureTests/ThreadCopyActionsTests.swift new file mode 100644 index 000000000000..e9f851b0f752 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/ThreadCopyActionsTests.swift @@ -0,0 +1,266 @@ +import XCTest +@testable import T3Code + +final class ThreadCopyActionsTests: XCTestCase { + func testActionsMatchElectronOrderAndCopyExactRawValues() { + let thread = FeatureThread( + id: "scoped-thread-id", + wireID: " thread-1 ", + projectID: "project-1", + title: "Copy values", + branch: " feature/copy-values ", + worktreePath: " /worktrees/copy-values " + ) + + let actions = ThreadCopyModel.actions( + for: thread, + context: context(projectWorkspaceRoot: "/work/t3code") + ) + XCTAssertEqual( + actions, + [ + ThreadCopyAction(kind: .path, value: " /worktrees/copy-values "), + ThreadCopyAction(kind: .branch, value: " feature/copy-values "), + ThreadCopyAction(kind: .threadID, value: " thread-1 "), + ] + ) + XCTAssertTrue(actions.allSatisfy(\.isAvailable)) + } + + func testMenuActionsMatchElectronCopyItems() { + let thread = FeatureThread( + id: "thread-1", + wireID: "thread-1", + projectID: "project-1", + environmentID: "environment-1", + title: "Copy values", + branch: "feature/copy-values", + worktreePath: "/worktrees/copy-values" + ) + + XCTAssertEqual( + ThreadCopyModel.menuActions( + for: thread, + context: context( + projectName: "pingdotgg/t3code", + projectWorkspaceRoot: "/work/t3code", + environmentName: "Studio Mac", + environmentID: "environment-1" + ) + ).map(\.kind), + [.path, .branch, .threadID] + ) + } + + func testPathFallsBackToProjectWorkspaceRoot() { + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + title: "Local checkout" + ) + + XCTAssertEqual( + ThreadCopyModel.actions( + for: thread, + context: context(projectWorkspaceRoot: "/work/t3code") + ), + [ + ThreadCopyAction(kind: .path, value: "/work/t3code"), + ThreadCopyAction(kind: .threadID, value: "thread-1"), + ] + ) + } + + func testBlankWorktreePathFallsBackToProjectWorkspaceRoot() { + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + title: "Local checkout", + worktreePath: " " + ) + + XCTAssertEqual( + ThreadCopyModel.actions( + for: thread, + context: context(projectWorkspaceRoot: "/work/t3code") + ).first, + ThreadCopyAction(kind: .path, value: "/work/t3code") + ) + } + + func testUnavailablePathRemainsVisibleAndBlankBranchIsOmitted() { + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + title: "No path", + branch: " \n " + ) + + let actions = ThreadCopyModel.actions(for: thread, context: context()) + XCTAssertEqual( + actions, + [ + ThreadCopyAction(kind: .path, value: nil), + ThreadCopyAction(kind: .threadID, value: "thread-1"), + ] + ) + XCTAssertFalse(actions[0].isAvailable) + XCTAssertTrue(actions[1].isAvailable) + } + + func testWorktreePathTakesPrecedenceOverProjectWorkspaceRoot() { + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + title: "Worktree", + worktreePath: "/worktrees/feature" + ) + + XCTAssertEqual( + ThreadCopyModel.actions( + for: thread, + context: context(projectWorkspaceRoot: "/work/t3code") + ).first, + ThreadCopyAction(kind: .path, value: "/worktrees/feature") + ) + } + + func testThreadIDUsesWireIdentityThenFallsBackToLocalIdentity() { + let wireThread = FeatureThread( + id: "scoped-thread-id", + wireID: "wire-thread-id", + projectID: "project-1", + title: "Wire identity" + ) + let localThread = FeatureThread( + id: "scoped-thread-id", + wireID: " ", + projectID: "project-1", + title: "Local identity" + ) + + XCTAssertEqual( + ThreadCopyModel.actions(for: wireThread, context: context()).last, + ThreadCopyAction(kind: .threadID, value: "wire-thread-id") + ) + XCTAssertEqual( + ThreadCopyModel.actions(for: localThread, context: context()).last, + ThreadCopyAction(kind: .threadID, value: "scoped-thread-id") + ) + } + + func testActionLabelsAndAnnouncementsAreDistinct() { + XCTAssertEqual(ThreadCopyActionKind.path.title, "Path") + XCTAssertEqual(ThreadCopyActionKind.path.copyAnnouncement, "Path copied") + XCTAssertEqual(ThreadCopyActionKind.branch.title, "Branch") + XCTAssertEqual(ThreadCopyActionKind.branch.copyAnnouncement, "Branch copied") + XCTAssertEqual(ThreadCopyActionKind.threadID.title, "Thread ID") + XCTAssertEqual(ThreadCopyActionKind.threadID.copyAnnouncement, "Thread ID copied") + XCTAssertEqual(ThreadCopyActionKind.project.title, "Project") + XCTAssertEqual(ThreadCopyActionKind.project.copyAnnouncement, "Project copied") + XCTAssertEqual(ThreadCopyActionKind.environment.title, "Environment") + XCTAssertEqual(ThreadCopyActionKind.environment.copyAnnouncement, "Environment copied") + XCTAssertEqual(ThreadCopyActionKind.url.title, "URL") + XCTAssertEqual(ThreadCopyActionKind.url.copyAnnouncement, "URL copied") + XCTAssertEqual( + ThreadCopyAction(kind: .path, value: nil).announcement, + "Path unavailable" + ) + XCTAssertEqual( + ThreadCopyAction(kind: .path, value: "/work/t3code").announcement, + "Path copied" + ) + } + + func testAvailableMetadataProducesOnlyIndividualActionsAndValues() { + let thread = FeatureThread( + id: "scoped-thread-id", + wireID: "thread-1", + projectID: "project-1", + environmentID: "environment-1", + title: "Copy values", + branch: "feature/copy-values", + worktreePath: "/worktrees/copy-values" + ) + + let actions = ThreadCopyModel.actions( + for: thread, + context: context( + projectName: "pingdotgg/t3code", + projectWorkspaceRoot: "/work/t3code", + environmentName: "Studio Mac", + environmentID: "fallback-environment" + ) + ) + + XCTAssertEqual( + actions, + [ + ThreadCopyAction(kind: .path, value: "/worktrees/copy-values"), + ThreadCopyAction(kind: .branch, value: "feature/copy-values"), + ThreadCopyAction(kind: .threadID, value: "thread-1"), + ThreadCopyAction(kind: .project, value: "pingdotgg/t3code"), + ThreadCopyAction(kind: .environment, value: "Studio Mac"), + ThreadCopyAction( + kind: .url, + value: "https://app.t3.codes/environment-1/thread-1" + ), + ] + ) + XCTAssertTrue(actions.allSatisfy { action in + action.value?.contains("\n") == false + }) + } + + func testUnavailableMetadataDoesNotProduceIndividualActions() { + let thread = FeatureThread( + id: "thread-1", + wireID: nil, + projectID: "project-1", + environmentID: nil, + title: "Sparse values" + ) + + let actions = ThreadCopyModel.actions(for: thread, context: context()) + + XCTAssertEqual( + actions.map(\.kind), + [.path, .threadID] + ) + XCTAssertFalse(actions.contains { action in + [.project, .environment, .url].contains(action.kind) + }) + } + + func testURLPercentEncodesRouteValuesAsSinglePathSegments() { + let thread = FeatureThread( + id: "scoped-thread-id", + wireID: "thread/one", + projectID: "project-1", + environmentID: "studio one", + title: "Encoded route" + ) + + let url = ThreadCopyModel.actions(for: thread, context: context()) + .first { $0.kind == .url } + + XCTAssertEqual( + url?.value, + "https://app.t3.codes/studio%20one/thread%2Fone" + ) + } + + private func context( + projectName: String? = nil, + projectWorkspaceRoot: String? = nil, + environmentName: String? = nil, + environmentID: String? = nil + ) -> ThreadCopyContext { + ThreadCopyContext( + projectName: projectName, + projectWorkspaceRoot: projectWorkspaceRoot, + environmentName: environmentName, + environmentID: environmentID + ) + } +}