Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
)
}
}

Expand Down Expand Up @@ -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)
}
Expand All @@ -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 {
Expand Down
144 changes: 144 additions & 0 deletions apps/swift-ios/Features/Workspace/ThreadCopyActions.swift
Original file line number Diff line number Diff line change
@@ -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) {
Comment thread
saphid marked this conversation as resolved.
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
)
}
}
15 changes: 14 additions & 1 deletion apps/swift-ios/Features/Workspace/WorkspaceView.swift
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -840,6 +840,15 @@ struct HomeThreadRowContext: Equatable {
connectionState: nil
)

var copyContext: ThreadCopyContext {
ThreadCopyContext(
projectName: projectWorkspaceRoot == nil ? nil : projectName,
projectWorkspaceRoot: projectWorkspaceRoot,
environmentName: environmentLabel,
environmentID: projectEnvironmentID
)
}
Comment thread
cursor[bot] marked this conversation as resolved.

var providerLooksTerminal: Bool {
let normalized = [providerDriver, providerID, providerName]
.joined(separator: " ")
Expand All @@ -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)
Expand Down
50 changes: 50 additions & 0 deletions apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading