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
13 changes: 13 additions & 0 deletions apps/swift-ios/Features/Shared/FeatureToolModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -825,6 +825,19 @@ public struct FeaturePullRequest: Sendable, Equatable, Hashable, Codable {
self.state = state
self.url = url
}

/// Only hand credential-free web destinations to the system browser.
public var safeExternalURL: URL? {
guard let url,
let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https",
url.host?.isEmpty == false,
url.user == nil,
url.password == nil else {
return nil
}
return url
}
}

public enum FeatureSourceControlAction: String, CaseIterable, Sendable, Codable {
Expand Down
122 changes: 119 additions & 3 deletions apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ struct HomeThreadCollectionView: UIViewRepresentable {

static func dismantleUIView(_ collectionView: UICollectionView, coordinator: Coordinator) {
coordinator.invalidateTimer()
coordinator.invalidatePullRequestLookups()
collectionView.delegate = nil
}

Expand All @@ -77,6 +78,12 @@ struct HomeThreadCollectionView: UIViewRepresentable {
private var timer: Timer?
private var timerTick = 0
private var timerInterval: TimeInterval = 0
private var pullRequestResolutions: [
HomeThreadPullRequestLookupKey: HomeThreadPullRequestResolution
] = [:]
private var pullRequestTasks: [
HomeThreadPullRequestLookupKey: (token: UUID, task: Task<Void, Never>)
] = [:]

init(parent: HomeThreadCollectionView) {
self.parent = parent
Expand Down Expand Up @@ -117,6 +124,7 @@ struct HomeThreadCollectionView: UIViewRepresentable {
seenIdentifiers.insert(item.id).inserted
}
itemsByID = Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) })
prunePullRequestLookups(to: Set(items.compactMap(\.pullRequestLookupKey)))
// After items land: picks 1 Hz when a working thread is present,
// 60s otherwise, and is a no-op when the interval is unchanged.
startTimer()
Expand Down Expand Up @@ -151,6 +159,12 @@ struct HomeThreadCollectionView: UIViewRepresentable {
timer = nil
}

func invalidatePullRequestLookups() {
pullRequestTasks.values.forEach { $0.task.cancel() }
pullRequestTasks.removeAll()
pullRequestResolutions.removeAll()
}

func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let item = item(at: indexPath) else { return }
switch item {
Expand Down Expand Up @@ -250,12 +264,21 @@ struct HomeThreadCollectionView: UIViewRepresentable {
now: Date
) {
guard let item = itemsByID[identifier] else { return }
let pullRequest: HomeThreadPullRequestPresentation?
if case let .thread(thread, _, _, _, _) = item {
loadPullRequestIfNeeded(for: thread)
pullRequest = HomeThreadPullRequestLookupKey(thread: thread)
.flatMap { pullRequestResolutions[$0]?.presentation }
} else {
pullRequest = nil
}
cell.contentConfiguration = UIHostingConfiguration {
HomeCollectionCellContent(
item: item,
projectFaviconClient: parent.projectFaviconClient,
isSelected: identifier.threadID == selectedThreadID,
now: now
now: now,
pullRequest: pullRequest
)
}
.margins(.all, 0)
Expand All @@ -268,15 +291,27 @@ struct HomeThreadCollectionView: UIViewRepresentable {
}

private func configureAccessibility(_ cell: HomeCollectionCell, item: HomeCollectionItem) {
cell.accessibilityCustomActions = nil
switch item {
case let .thread(thread, context, _, _, _):
cell.isAccessibilityElement = true
cell.accessibilityTraits = selectedThreadID == thread.id
? [.button, .selected]
: .button
cell.accessibilityLabel = thread.title
cell.accessibilityValue = threadAccessibilityValue(thread, context: context)
let baseValue = threadAccessibilityValue(thread, context: context)
cell.accessibilityValue = baseValue
cell.accessibilityHint = "Opens task"
if let key = HomeThreadPullRequestLookupKey(thread: thread),
let pullRequest = pullRequestResolutions[key]?.presentation {
cell.accessibilityValue = pullRequest.accessibilityValue(appending: baseValue)
cell.accessibilityCustomActions = [
UIAccessibilityCustomAction(name: pullRequest.accessibilityActionName) { _ in
UIApplication.shared.open(pullRequest.destination)
return true
},
]
}
cell.onAccessibilityActivate = { [weak self] in
guard let self else { return }
let previousSelection = self.selectedThreadID
Expand Down Expand Up @@ -325,6 +360,51 @@ struct HomeThreadCollectionView: UIViewRepresentable {
}
}

private func loadPullRequestIfNeeded(for thread: FeatureThread) {
guard let key = HomeThreadPullRequestLookupKey(thread: thread),
pullRequestResolutions[key] == nil,
pullRequestTasks[key] == nil else { return }
let token = UUID()
let client = parent.projectFaviconClient
let task = Task { [weak self] in
let presentation: HomeThreadPullRequestPresentation?
do {
let status = try await client.sourceControlStatus(threadID: thread.id)
presentation = HomeThreadPullRequestPresentation(thread: thread, status: status)
} catch {
guard let self, pullRequestTasks[key]?.token == token else { return }
pullRequestTasks[key] = nil
return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unbounded PR lookup retries

High Severity

On sourceControlStatus failure, the task entry is cleared and no cooldown is recorded. Visible working rows reconfigure about once per second, so loadPullRequestIfNeeded can immediately retry and call refreshVCSStatus, which also invalidates the server PR-lookup cache.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5f5a76b. Configure here.

}
guard !Task.isCancelled, let self,
pullRequestTasks[key]?.token == token else { return }
pullRequestTasks[key] = nil
pullRequestResolutions[key] = .resolved(presentation)
reconfigureThreadRows(matching: key)
}
pullRequestTasks[key] = (token, task)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale PR lookup cache

Medium Severity

loadPullRequestIfNeeded treats any stored HomeThreadPullRequestResolution as final, including .resolved(nil). While a checkout key stays visible, the row never refetches, so a missing PR can stay hidden and an existing #number or state can stay outdated after remote changes.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5f5a76b. Configure here.


private func prunePullRequestLookups(to activeKeys: Set<HomeThreadPullRequestLookupKey>) {
pullRequestResolutions = pullRequestResolutions.filter { activeKeys.contains($0.key) }
let inactiveTasks = pullRequestTasks.filter { !activeKeys.contains($0.key) }
for (key, lookup) in inactiveTasks {
lookup.task.cancel()
pullRequestTasks[key] = nil
}
}

private func reconfigureThreadRows(matching key: HomeThreadPullRequestLookupKey) {
guard let dataSource else { return }
let identifiers = itemsByID.compactMap { identifier, item in
item.pullRequestLookupKey == key ? identifier : nil
}
guard !identifiers.isEmpty else { return }
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems(identifiers)
dataSource.apply(snapshot, animatingDifferences: false)
}

private func threadAccessibilityValue(
_ thread: FeatureThread,
context: HomeThreadRowContext
Expand Down Expand Up @@ -645,6 +725,7 @@ private struct HomeCollectionCellContent: View {
let projectFaviconClient: any FeatureClient
let isSelected: Bool
let now: Date
let pullRequest: HomeThreadPullRequestPresentation?

@ViewBuilder
var body: some View {
Expand All @@ -657,7 +738,8 @@ private struct HomeCollectionCellContent: View {
isSelected: isSelected,
style: style,
now: now,
allowsMultilineTitle: allowsMultilineTitle
allowsMultilineTitle: allowsMultilineTitle,
pullRequest: pullRequest
)
case let .shelfHeader(shelf, count, isExpanded):
HomeShelfHeader(
Expand Down Expand Up @@ -697,6 +779,40 @@ private struct HomeCollectionCellContent: View {
}
}

struct HomeThreadPullRequestLookupKey: Hashable {
let environmentID: String?
let projectID: String
let branch: String
let worktreePath: String?

init?(thread: FeatureThread) {
guard let branch = thread.branch?.trimmingCharacters(in: .whitespacesAndNewlines),
!branch.isEmpty else { return nil }
environmentID = thread.environmentID
projectID = thread.projectID
self.branch = branch
let path = thread.worktreePath?.trimmingCharacters(in: .whitespacesAndNewlines)
worktreePath = path?.isEmpty == false ? path : nil
}
}

private enum HomeThreadPullRequestResolution {
case resolved(HomeThreadPullRequestPresentation?)

var presentation: HomeThreadPullRequestPresentation? {
switch self {
case let .resolved(presentation): presentation
}
}
}

private extension HomeCollectionItem {
var pullRequestLookupKey: HomeThreadPullRequestLookupKey? {
guard case let .thread(thread, _, _, _, _) = self else { return nil }
return HomeThreadPullRequestLookupKey(thread: thread)
}
}

private extension Optional where Wrapped == [IndexPath] {
var orEmpty: [IndexPath] { self ?? [] }
}
75 changes: 74 additions & 1 deletion apps/swift-ios/Features/Workspace/WorkspaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -857,6 +857,45 @@ struct HomeThreadRowContext: Equatable {
}
}

struct HomeThreadPullRequestPresentation: Equatable {
let number: Int
let state: String
let destination: URL

var shortLabel: String { "#\(number)" }
var accessibilityLabel: String { "Pull request \(number), \(state)" }
var accessibilityActionName: String { "Open pull request \(number), \(state)" }
func accessibilityValue(appending baseValue: String) -> String {
"\(baseValue). \(accessibilityLabel)."
}

init?(
thread: FeatureThread,
status: FeatureSourceControlStatus
) {
guard let threadBranch = Self.normalizedBranch(thread.branch),
let statusBranch = Self.normalizedBranch(status.branch),
threadBranch == statusBranch,
let pullRequest = status.pullRequest,
pullRequest.number > 0,
let destination = pullRequest.safeExternalURL else {
return nil
}
let normalizedState = pullRequest.state.trimmingCharacters(in: .whitespacesAndNewlines)
number = pullRequest.number
state = normalizedState.isEmpty ? "unknown state" : normalizedState
self.destination = destination
}

private static func normalizedBranch(_ branch: String?) -> String? {
guard let branch = branch?.trimmingCharacters(in: .whitespacesAndNewlines),
!branch.isEmpty else {
return nil
}
return branch
}
}

struct FeatureThreadRow: View {
enum Style: Equatable {
case rich
Expand All @@ -870,6 +909,7 @@ struct FeatureThreadRow: View {
let style: Style
let now: Date
let allowsMultilineTitle: Bool
let pullRequest: HomeThreadPullRequestPresentation?

init(
thread: FeatureThread,
Expand All @@ -878,7 +918,8 @@ struct FeatureThreadRow: View {
isSelected: Bool = false,
style: Style = .rich,
now: Date = .now,
allowsMultilineTitle: Bool = false
allowsMultilineTitle: Bool = false,
pullRequest: HomeThreadPullRequestPresentation? = nil
) {
self.thread = thread
self.context = context
Expand All @@ -887,6 +928,7 @@ struct FeatureThreadRow: View {
self.style = style
self.now = now
self.allowsMultilineTitle = allowsMultilineTitle
self.pullRequest = pullRequest
}

var body: some View {
Expand Down Expand Up @@ -941,6 +983,7 @@ struct FeatureThreadRow: View {
.foregroundStyle(T3Colors.syntaxProperty)
}
Spacer(minLength: 8)
pullRequestLink
if let environmentLabel {
HStack(spacing: 4) {
Image(systemName: environmentIcon)
Expand Down Expand Up @@ -982,6 +1025,7 @@ struct FeatureThreadRow: View {
.foregroundStyle(T3Colors.textSecondary)
.lineLimit(allowsMultilineTitle ? 2 : 1)
Spacer(minLength: 8)
pullRequestLink
if thread.pinnedAt != nil {
Image(systemName: "pin.fill")
.font(.system(size: 9, weight: .semibold))
Expand All @@ -1001,6 +1045,35 @@ struct FeatureThreadRow: View {
)
}

@ViewBuilder
private var pullRequestLink: some View {
if let pullRequest {
Link(destination: pullRequest.destination) {
HStack(spacing: 3) {
Text(pullRequest.shortLabel)
.monospacedDigit()
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
Image(systemName: "arrow.up.right")
.font(.system(size: 8, weight: .bold))
}
.font(T3Typography.homeMetadata.weight(.semibold))
.foregroundStyle(T3Colors.accent)
.contentShape(Rectangle())
}
.padding(.horizontal, 7)
.padding(.vertical, 15)
.contentShape(Rectangle())
.padding(.horizontal, -7)
.padding(.vertical, -15)
.buttonStyle(.plain)
.layoutPriority(1)
.accessibilityLabel(pullRequest.accessibilityLabel)
.accessibilityHint("Opens pull request in the browser")
.accessibilityIdentifier("thread-\(thread.id)-pull-request")
}
}

@ViewBuilder
private func status(at now: Date) -> some View {
let label = thread.homeStatusLabel
Expand Down
Loading
Loading