From 128be97e20a609be19fd3dac441aaf09d3b65dc2 Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Fri, 31 Jul 2026 18:39:10 +0200 Subject: [PATCH] Show milestone and pull request states --- TESTING.md | 9 ++-- crates/app/src/presentation.rs | 24 +++++++++ ios/Generated/gotcha_core.swift | 18 +++++-- ios/Sources/ContentScreens.swift | 92 +++++++++++++++++++++++++------- ios/Sources/DetailScreens.swift | 17 ++++-- ios/Sources/Support.swift | 21 ++++++-- 6 files changed, 148 insertions(+), 33 deletions(-) diff --git a/TESTING.md b/TESTING.md index 89bf2ba..9a973af 100644 --- a/TESTING.md +++ b/TESTING.md @@ -267,9 +267,11 @@ xcrun simctl launch booted de.rfc1437.gotcha Open + All Milestones, refreshes the rows, returns both icons to neutral, and remains cleared after relaunch. - [ ] Each row shows repository/number, title, author/update metadata, comment - count, and draft/merged state where applicable. + count, and draft/merged state where applicable. Open rows show a green + icon, closed rows show a purple icon, and VoiceOver announces the state. - [ ] Open a pull request and verify title, metadata, Markdown body, comments, - and changed-file reference. Its file paths, statuses, left alignment, + changed-file reference, and matching open/closed state icon with a + VoiceOver state announcement. Its file paths, statuses, left alignment, separators, and disclosure indicators match the commit changed-file list. - [ ] Open a changed file and run the same diff checks as for a commit. @@ -280,7 +282,8 @@ xcrun simctl launch booted de.rfc1437.gotcha - [ ] Toggle a repository favorite and confirm it persists after refresh and relaunch without affecting the same repository in Issues or Repos. - [ ] Each milestone shows its title, description, state, due date, issue - counts, and a green/amber closed/open progress bar. + counts, and a green/amber closed/open progress bar. Lists and details show + a green open or purple closed icon and VoiceOver announces the state. - [ ] Open a milestone and verify every assigned issue and pull request is listed; tapping either opens its normal detail. - [ ] Tap the add button. **New Milestone** has native Cancel and Save controls, diff --git a/crates/app/src/presentation.rs b/crates/app/src/presentation.rs index 03d4a68..13167e1 100644 --- a/crates/app/src/presentation.rs +++ b/crates/app/src/presentation.rs @@ -57,6 +57,7 @@ pub struct PullRow { pub number: i64, pub owner: String, pub repository: String, + pub state: String, pub title: String, pub summary: String, pub meta: String, @@ -118,6 +119,7 @@ pub struct IssueFilterOptions { #[derive(Clone, uniffi::Record)] pub struct MilestoneRow { pub id: i64, + pub state: String, pub title: String, pub description: String, pub meta: String, @@ -156,6 +158,7 @@ pub struct PullFilterOptions { #[derive(Clone, uniffi::Record)] pub struct PullPage { pub title: String, + pub state: String, pub meta: String, pub body: String, pub files_ref: String, @@ -443,6 +446,7 @@ pub fn pull_rows(pulls: &[models::Issue]) -> Vec { number: pull.number.unwrap_or_default(), owner: repository.owner.clone()?, repository: repository.name.clone()?, + state: pull.state.clone().unwrap_or_else(|| "unknown".into()), title: format!( "{} #{}\n{}", repository.name.as_deref()?, @@ -664,6 +668,7 @@ pub fn pull_page(details: PullDetails) -> PullPage { .title .clone() .unwrap_or_else(|| "Untitled pull request".into()), + state: pull.state.clone().unwrap_or_else(|| "unknown".into()), meta: format!( "#{} · {state} · {author} · {head} → {base}\n{} files · +{} −{} · {} comments", pull.number.unwrap_or_default(), @@ -1077,6 +1082,7 @@ fn milestone_row(milestone: &models::Milestone) -> MilestoneRow { .unwrap_or_else(|| "no due date".into()); MilestoneRow { id: milestone.id.unwrap_or_default(), + state: milestone.state.clone().unwrap_or_else(|| "unknown".into()), title: milestone .title .clone() @@ -1239,6 +1245,7 @@ mod tests { }; let milestone_row = milestone_rows(std::slice::from_ref(&milestone)).remove(0); assert_eq!(milestone_row.progress, 0.4); + assert_eq!(milestone_row.state, "open"); assert_eq!(milestone_row.progress_accessibility, "2 closed, 3 open"); let issue = models::Issue { state: Some("closed".into()), @@ -1406,6 +1413,7 @@ mod tests { issues: Vec::new(), pulls: vec![models::Issue { number: Some(7), + state: Some("closed".into()), title: Some("Linked pull".into()), repository: Some(Box::new(models::RepositoryMeta { name: Some("demo".into()), @@ -1420,6 +1428,22 @@ mod tests { assert!(page.issues.is_empty()); assert_eq!(page.pulls.len(), 1); assert_eq!(page.pulls[0].number, 7); + assert_eq!(page.pulls[0].state, "closed"); + } + + #[test] + fn pull_page_exposes_open_closed_state() { + let page = pull_page(PullDetails { + pull: models::PullRequest { + state: Some("closed".into()), + ..Default::default() + }, + comments: Vec::new(), + files: Vec::new(), + has_more: false, + }); + + assert_eq!(page.state, "closed"); } #[test] diff --git a/ios/Generated/gotcha_core.swift b/ios/Generated/gotcha_core.swift index 1b29696..8cbe97e 100644 --- a/ios/Generated/gotcha_core.swift +++ b/ios/Generated/gotcha_core.swift @@ -2617,6 +2617,7 @@ public func FfiConverterTypeMilestonePage_lower(_ value: MilestonePage) -> RustB public struct MilestoneRow: Equatable, Hashable { public var id: Int64 + public var state: String public var title: String public var description: String public var meta: String @@ -2626,8 +2627,9 @@ public struct MilestoneRow: Equatable, Hashable { // Default memberwise initializers are never public by default, so we // declare one manually. - public init(id: Int64, title: String, description: String, meta: String, hasIssues: Bool, progress: Double, progressAccessibility: String) { + public init(id: Int64, state: String, title: String, description: String, meta: String, hasIssues: Bool, progress: Double, progressAccessibility: String) { self.id = id + self.state = state self.title = title self.description = description self.meta = meta @@ -2653,6 +2655,7 @@ public struct FfiConverterTypeMilestoneRow: FfiConverterRustBuffer { return try MilestoneRow( id: FfiConverterInt64.read(from: &buf), + state: FfiConverterString.read(from: &buf), title: FfiConverterString.read(from: &buf), description: FfiConverterString.read(from: &buf), meta: FfiConverterString.read(from: &buf), @@ -2664,6 +2667,7 @@ public struct FfiConverterTypeMilestoneRow: FfiConverterRustBuffer { public static func write(_ value: MilestoneRow, into buf: inout [UInt8]) { FfiConverterInt64.write(value.id, into: &buf) + FfiConverterString.write(value.state, into: &buf) FfiConverterString.write(value.title, into: &buf) FfiConverterString.write(value.description, into: &buf) FfiConverterString.write(value.meta, into: &buf) @@ -2799,6 +2803,7 @@ public func FfiConverterTypePullListPage_lower(_ value: PullListPage) -> RustBuf public struct PullPage: Equatable, Hashable { public var title: String + public var state: String public var meta: String public var body: String public var filesRef: String @@ -2808,8 +2813,9 @@ public struct PullPage: Equatable, Hashable { // Default memberwise initializers are never public by default, so we // declare one manually. - public init(title: String, meta: String, body: String, filesRef: String, files: [FileRow], comments: [CommentRow], hasMore: Bool) { + public init(title: String, state: String, meta: String, body: String, filesRef: String, files: [FileRow], comments: [CommentRow], hasMore: Bool) { self.title = title + self.state = state self.meta = meta self.body = body self.filesRef = filesRef @@ -2835,6 +2841,7 @@ public struct FfiConverterTypePullPage: FfiConverterRustBuffer { return try PullPage( title: FfiConverterString.read(from: &buf), + state: FfiConverterString.read(from: &buf), meta: FfiConverterString.read(from: &buf), body: FfiConverterString.read(from: &buf), filesRef: FfiConverterString.read(from: &buf), @@ -2846,6 +2853,7 @@ public struct FfiConverterTypePullPage: FfiConverterRustBuffer { public static func write(_ value: PullPage, into buf: inout [UInt8]) { FfiConverterString.write(value.title, into: &buf) + FfiConverterString.write(value.state, into: &buf) FfiConverterString.write(value.meta, into: &buf) FfiConverterString.write(value.body, into: &buf) FfiConverterString.write(value.filesRef, into: &buf) @@ -2875,16 +2883,18 @@ public struct PullRow: Equatable, Hashable { public var number: Int64 public var owner: String public var repository: String + public var state: String public var title: String public var summary: String public var meta: String // Default memberwise initializers are never public by default, so we // declare one manually. - public init(number: Int64, owner: String, repository: String, title: String, summary: String, meta: String) { + public init(number: Int64, owner: String, repository: String, state: String, title: String, summary: String, meta: String) { self.number = number self.owner = owner self.repository = repository + self.state = state self.title = title self.summary = summary self.meta = meta @@ -2909,6 +2919,7 @@ public struct FfiConverterTypePullRow: FfiConverterRustBuffer { number: FfiConverterInt64.read(from: &buf), owner: FfiConverterString.read(from: &buf), repository: FfiConverterString.read(from: &buf), + state: FfiConverterString.read(from: &buf), title: FfiConverterString.read(from: &buf), summary: FfiConverterString.read(from: &buf), meta: FfiConverterString.read(from: &buf) @@ -2919,6 +2930,7 @@ public struct FfiConverterTypePullRow: FfiConverterRustBuffer { FfiConverterInt64.write(value.number, into: &buf) FfiConverterString.write(value.owner, into: &buf) FfiConverterString.write(value.repository, into: &buf) + FfiConverterString.write(value.state, into: &buf) FfiConverterString.write(value.title, into: &buf) FfiConverterString.write(value.summary, into: &buf) FfiConverterString.write(value.meta, into: &buf) diff --git a/ios/Sources/ContentScreens.swift b/ios/Sources/ContentScreens.swift index 01fb2cb..82d8e22 100644 --- a/ios/Sources/ContentScreens.swift +++ b/ios/Sources/ContentScreens.swift @@ -518,6 +518,7 @@ final class MilestoneViewController: RefreshingTableViewController { super.viewDidLoad() tableView.register(MilestoneCell.self, forCellReuseIdentifier: "milestone") tableView.register(IssueCell.self, forCellReuseIdentifier: "issue") + tableView.register(PullCell.self, forCellReuseIdentifier: "pull") tableView.rowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = 118 let editButton = UIBarButtonItem( @@ -633,11 +634,8 @@ final class MilestoneViewController: RefreshingTableViewController { cell.configure(page.issues[indexPath.row]) return cell } - let cell = tableView.dequeueReusableCell(withIdentifier: "pull") - ?? UITableViewCell(style: .subtitle, reuseIdentifier: "pull") - let pull = page.pulls[indexPath.row] - configureTextCell(cell, title: pull.title, detail: "\(pull.summary)\n\(pull.meta)") - cell.accessoryType = .disclosureIndicator + let cell = tableView.dequeueReusableCell(withIdentifier: "pull", for: indexPath) as! PullCell + cell.configure(page.pulls[indexPath.row]) return cell } @@ -668,6 +666,7 @@ final class MilestoneViewController: RefreshingTableViewController { } final class MilestoneCell: UITableViewCell { + private let stateIcon = UIImageView() private let titleLabel = UILabel() private let descriptionLabel = UILabel() private let metaLabel = UILabel() @@ -677,6 +676,9 @@ final class MilestoneCell: UITableViewCell { super.init(style: style, reuseIdentifier: reuseIdentifier) titleLabel.font = .preferredFont(forTextStyle: .headline) titleLabel.numberOfLines = 2 + let titleStack = UIStackView(arrangedSubviews: [stateIcon, titleLabel]) + titleStack.alignment = .firstBaseline + titleStack.spacing = 8 descriptionLabel.font = .preferredFont(forTextStyle: .subheadline) descriptionLabel.textColor = .secondaryLabel descriptionLabel.numberOfLines = 2 @@ -684,7 +686,9 @@ final class MilestoneCell: UITableViewCell { metaLabel.textColor = .tertiaryLabel metaLabel.numberOfLines = 2 progress.progressTintColor = .systemGreen - let stack = UIStackView(arrangedSubviews: [titleLabel, descriptionLabel, progress, metaLabel]) + let stack = UIStackView( + arrangedSubviews: [titleStack, descriptionLabel, progress, metaLabel] + ) stack.axis = .vertical stack.spacing = 7 stack.translatesAutoresizingMaskIntoConstraints = false @@ -702,6 +706,12 @@ final class MilestoneCell: UITableViewCell { func configure(_ row: MilestoneRow, disclosure: Bool) { accessoryType = disclosure ? .disclosureIndicator : .none + configureOpenClosedStateIcon( + stateIcon, + state: row.state, + subject: "milestone", + textStyle: .headline + ) titleLabel.text = row.title descriptionLabel.text = row.description metaLabel.text = row.meta @@ -712,6 +722,58 @@ final class MilestoneCell: UITableViewCell { } } +final class PullCell: UITableViewCell { + private let stateIcon = UIImageView() + private let titleLabel = UILabel() + private let summaryLabel = UILabel() + private let metaLabel = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + accessoryType = .disclosureIndicator + titleLabel.font = .preferredFont(forTextStyle: .headline) + titleLabel.numberOfLines = 2 + let titleStack = UIStackView(arrangedSubviews: [stateIcon, titleLabel]) + titleStack.alignment = .firstBaseline + titleStack.spacing = 8 + summaryLabel.font = .preferredFont(forTextStyle: .subheadline) + summaryLabel.textColor = .secondaryLabel + summaryLabel.numberOfLines = 2 + metaLabel.font = .preferredFont(forTextStyle: .caption1) + metaLabel.textColor = .tertiaryLabel + metaLabel.numberOfLines = 2 + [titleLabel, summaryLabel, metaLabel].forEach { + $0.adjustsFontForContentSizeCategory = true + } + let stack = UIStackView(arrangedSubviews: [titleStack, summaryLabel, metaLabel]) + stack.axis = .vertical + stack.spacing = 6 + stack.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8), + stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10), + stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + func configure(_ row: PullRow) { + configureOpenClosedStateIcon( + stateIcon, + state: row.state, + subject: "pull request", + textStyle: .headline + ) + titleLabel.text = row.title + summaryLabel.text = row.summary + metaLabel.text = row.meta + } +} + @MainActor final class PullsViewController: RefreshingTableViewController { private let context: AppContext @@ -733,6 +795,9 @@ final class PullsViewController: RefreshingTableViewController { override func viewDidLoad() { super.viewDidLoad() + tableView.register(PullCell.self, forCellReuseIdentifier: "pull") + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 116 navigationItem.leftBarButtonItem = UIBarButtonItem( image: context.symbol("server.rack"), primaryAction: UIAction { [weak self] _ in @@ -821,22 +886,11 @@ final class PullsViewController: RefreshingTableViewController { _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "pull") - ?? UITableViewCell(style: .subtitle, reuseIdentifier: "pull") - let row = rows[indexPath.row] - configureTextCell( - cell, - title: row.title, - detail: "\(row.summary)\n\(row.meta)" - ) - cell.accessoryType = .disclosureIndicator + let cell = tableView.dequeueReusableCell(withIdentifier: "pull", for: indexPath) as! PullCell + cell.configure(rows[indexPath.row]) return cell } - override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - 116 - } - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) let row = rows[indexPath.row] diff --git a/ios/Sources/DetailScreens.swift b/ios/Sources/DetailScreens.swift index 82bee54..95137a7 100644 --- a/ios/Sources/DetailScreens.swift +++ b/ios/Sources/DetailScreens.swift @@ -227,7 +227,8 @@ final class IssueViewController: MarkdownPageViewController { if let page { replaceContent(detailViews( title: page.title, - issueState: page.state, + state: page.state, + stateSubject: "issue", meta: page.meta, body: page.body, comments: page.comments, @@ -305,6 +306,8 @@ final class PullViewController: MarkdownPageViewController { if let page { var views = detailViews( title: page.title, + state: page.state, + stateSubject: "pull request", meta: page.meta, body: page.body, comments: [] @@ -1138,7 +1141,8 @@ final class DiffViewController: UIViewController { private func detailViews( title: String, - issueState: String? = nil, + state: String? = nil, + stateSubject: String = "", meta: String, body: String, comments: [CommentRow], @@ -1156,7 +1160,10 @@ private func detailViews( metaLabel.textColor = .secondaryLabel metaLabel.numberOfLines = 0 let bodyView = markdownView(body) - var views: [UIView] = [issueState.map { issueTitle(titleLabel, state: $0) } ?? titleLabel, metaLabel] + var views: [UIView] = [ + state.map { stateTitle(titleLabel, state: $0, subject: stateSubject) } ?? titleLabel, + metaLabel, + ] if !milestone.isEmpty { let milestoneLabel = UILabel() milestoneLabel.attributedText = symbolText( @@ -1172,9 +1179,9 @@ private func detailViews( return views + [separator(), bodyView] + commentViews(comments, editComment: editComment) } -private func issueTitle(_ titleLabel: UILabel, state: String) -> UIView { +private func stateTitle(_ titleLabel: UILabel, state: String, subject: String) -> UIView { let icon = UIImageView() - configureIssueStateIcon(icon, state: state, textStyle: .title1) + configureOpenClosedStateIcon(icon, state: state, subject: subject, textStyle: .title1) let stack = UIStackView(arrangedSubviews: [icon, titleLabel]) stack.axis = .horizontal stack.alignment = .firstBaseline diff --git a/ios/Sources/Support.swift b/ios/Sources/Support.swift index a8daca3..11ceba0 100644 --- a/ios/Sources/Support.swift +++ b/ios/Sources/Support.swift @@ -267,17 +267,32 @@ func configureIssueStateIcon( _ icon: UIImageView, state: String, textStyle: UIFont.TextStyle +) { + configureOpenClosedStateIcon(icon, state: state, subject: "issue", textStyle: textStyle) +} + +func configureOpenClosedStateIcon( + _ icon: UIImageView, + state: String, + subject: String, + textStyle: UIFont.TextStyle ) { let symbol: String let color: UIColor let accessibilityLabel: String switch state { case "open": - (symbol, color, accessibilityLabel) = ("exclamationmark.circle.fill", .systemGreen, "Open issue") + (symbol, color, accessibilityLabel) = ( + "exclamationmark.circle.fill", .systemGreen, "Open \(subject)" + ) case "closed": - (symbol, color, accessibilityLabel) = ("checkmark.circle.fill", .systemPurple, "Closed issue") + (symbol, color, accessibilityLabel) = ( + "checkmark.circle.fill", .systemPurple, "Closed \(subject)" + ) default: - (symbol, color, accessibilityLabel) = ("questionmark.circle.fill", .systemGray, "Unknown issue state") + (symbol, color, accessibilityLabel) = ( + "questionmark.circle.fill", .systemGray, "Unknown \(subject) state" + ) } icon.image = UIImage(systemName: symbol) icon.tintColor = color