-
Notifications
You must be signed in to change notification settings - Fork 4.1k
feat(swift-ios): link thread rows to pull requests #5804
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: t3code/rebuild-mobile-app-swift
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -59,6 +59,7 @@ struct HomeThreadCollectionView: UIViewRepresentable { | |
|
|
||
| static func dismantleUIView(_ collectionView: UICollectionView, coordinator: Coordinator) { | ||
| coordinator.invalidateTimer() | ||
| coordinator.invalidatePullRequestLookups() | ||
| collectionView.delegate = nil | ||
| } | ||
|
|
||
|
|
@@ -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 | ||
|
|
@@ -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() | ||
|
|
@@ -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 { | ||
|
|
@@ -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) | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
| } | ||
| 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) | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Stale PR lookup cacheMedium Severity
Additional Locations (1)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 | ||
|
|
@@ -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 { | ||
|
|
@@ -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( | ||
|
|
@@ -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 ?? [] } | ||
| } | ||


There was a problem hiding this comment.
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
sourceControlStatusfailure, the task entry is cleared and no cooldown is recorded. Visible working rows reconfigure about once per second, soloadPullRequestIfNeededcan immediately retry and callrefreshVCSStatus, which also invalidates the server PR-lookup cache.Additional Locations (1)
apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift#L552-L567Reviewed by Cursor Bugbot for commit 5f5a76b. Configure here.