From 3a69b7039526209fe23ea9c69f47dd19f8db4fd3 Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Sat, 1 Aug 2026 14:27:28 +0200 Subject: [PATCH] Show commit details above changed files --- TESTING.md | 6 +- crates/app/src/api.rs | 6 +- crates/app/src/lib.rs | 10 +- crates/app/src/presentation.rs | 165 ++++++++++++++++++++++++++++ crates/gitea/src/repositories.rs | 9 +- ios/Generated/gotcha_core.swift | 181 +++++++++++++++++++++++++++---- ios/Generated/gotcha_coreFFI.h | 22 ++-- ios/Sources/ContentScreens.swift | 3 +- ios/Sources/DetailScreens.swift | 116 ++++++++++++++++++-- 9 files changed, 461 insertions(+), 57 deletions(-) diff --git a/TESTING.md b/TESTING.md index 4737278..bae01ae 100644 --- a/TESTING.md +++ b/TESTING.md @@ -217,7 +217,11 @@ xcrun simctl launch booted de.rfc1437.gotcha - [ ] In **All** history, branch names use the accent color both at branch tips and at the first commit made on a pull-request branch, including a merged branch whose tip was deleted. -- [ ] Open a commit and verify Changed Files paths and statuses. +- [ ] Open a commit and verify its header shows the full title and description, + full hash, author, committer, date, known branch/ref, and signature status + above the Changed Files paths and statuses. Unsigned commits say + **Unsigned**. Check that long values wrap and remain readable at the + largest Dynamic Type accessibility size. - [ ] Open a changed file and verify the diff title, old/new line numbers, monospaced text, addition/removal/hunk colors, vertical scrolling, and source-style horizontal scrolling for long lines without word wrapping, diff --git a/crates/app/src/api.rs b/crates/app/src/api.rs index 9c5cea8..3307cda 100644 --- a/crates/app/src/api.rs +++ b/crates/app/src/api.rs @@ -316,14 +316,14 @@ pub async fn load_pull( .map_err(message) } -pub async fn load_commit_files( +pub async fn load_commit( server: &Server, owner: &str, repository: &str, sha: &str, -) -> Result, String> { +) -> Result { client(server)? - .commit_files(&scope(owner, repository)?, sha) + .commit(&scope(owner, repository)?, sha) .await .map_err(message) } diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 61ba555..9c96fad 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -726,14 +726,16 @@ impl GotchaCore { Ok(repository_file_page(&path, data)) } - pub async fn commit_files( + pub async fn commit_details( &self, owner: String, repository: String, sha: String, - ) -> Result, GotchaError> { - Ok(commit_file_rows( - load_commit_files(&self.server()?, &owner, &repository, &sha).await?, + branch: Option, + ) -> Result { + Ok(commit_details_page( + load_commit(&self.server()?, &owner, &repository, &sha).await?, + branch, )) } diff --git a/crates/app/src/presentation.rs b/crates/app/src/presentation.rs index c2144b0..11afa02 100644 --- a/crates/app/src/presentation.rs +++ b/crates/app/src/presentation.rs @@ -196,6 +196,20 @@ pub struct CommitPage { pub has_more: bool, } +#[derive(Clone, uniffi::Record)] +pub struct CommitMetadataRow { + pub label: String, + pub value: String, +} + +#[derive(Clone, uniffi::Record)] +pub struct CommitDetailsPage { + pub title: String, + pub description: String, + pub metadata: Vec, + pub files: Vec, +} + #[derive(Clone, uniffi::Record)] pub struct FileRow { pub path: String, @@ -716,6 +730,97 @@ pub fn commit_file_rows(files: Vec) -> Vec .collect() } +pub fn commit_details_page(commit: models::Commit, branch: Option) -> CommitDetailsPage { + let details = commit.commit.as_deref(); + let message = details + .and_then(|details| details.message.as_deref()) + .unwrap_or("Commit"); + let mut lines = message.lines(); + let title = lines.next().unwrap_or("Commit").to_string(); + let description = lines.collect::>().join("\n").trim().to_string(); + let author = details + .and_then(|details| details.author.as_deref()) + .and_then(commit_user) + .or_else(|| { + commit + .author + .as_deref() + .and_then(|author| author.login.clone()) + }); + let committer = details + .and_then(|details| details.committer.as_deref()) + .and_then(commit_user) + .or_else(|| { + commit + .committer + .as_deref() + .and_then(|committer| committer.login.clone()) + }); + let date = details + .and_then(|details| details.committer.as_deref()) + .and_then(|committer| committer.date.as_deref()) + .or_else(|| { + details + .and_then(|details| details.author.as_deref()) + .and_then(|author| author.date.as_deref()) + }) + .or(commit.created.as_deref()); + let files = commit.files.unwrap_or_default(); + let mut metadata = Vec::new(); + if let Some(author) = author { + metadata.push(metadata_row("Author", author)); + } + if let Some(committer) = committer { + metadata.push(metadata_row("Committer", committer)); + } + if date.is_some() { + metadata.push(metadata_row("Committed", compact_date(date))); + } + if let Some(branch) = branch.filter(|branch| !branch.is_empty()) { + metadata.push(metadata_row("Branch", branch)); + } + metadata.push(metadata_row( + "Commit", + commit.sha.clone().unwrap_or_else(|| "unknown".into()), + )); + let verification = details.and_then(|details| details.verification.as_deref()); + metadata.push(metadata_row( + "Signature", + match verification.filter(|verification| { + verification + .signature + .as_deref() + .is_some_and(|signature| !signature.is_empty()) + }) { + Some(verification) if verification.verified.unwrap_or(false) => "Verified".into(), + Some(_) => "Unverified".into(), + None => "Unsigned".into(), + }, + )); + CommitDetailsPage { + title, + description, + metadata, + files: commit_file_rows(files), + } +} + +fn commit_user(user: &models::CommitUser) -> Option { + match (user.name.as_deref(), user.email.as_deref()) { + (Some(name), Some(email)) => Some(format!("{name} <{email}>")), + (Some(name), None) => Some(name.into()), + (None, Some(email)) => Some(email.into()), + (None, None) => None, + } +} + +fn metadata_row(label: &str, value: String) -> CommitMetadataRow { + CommitMetadataRow { + label: label.into(), + value, + } +} + pub fn repository_content_rows( contents: Vec, ) -> Vec { @@ -1215,6 +1320,66 @@ mod tests { ); } + #[test] + fn presents_complete_commit_details() { + let page = commit_details_page( + models::Commit { + sha: Some("0123456789abcdef".into()), + commit: Some(Box::new(models::RepoCommit { + message: Some("Add details\n\nExplain the change.".into()), + author: Some(Box::new(models::CommitUser { + name: Some("Ada".into()), + email: Some("ada@example.com".into()), + date: Some("2026-08-01T10:00:00Z".into()), + })), + committer: Some(Box::new(models::CommitUser { + name: Some("Grace".into()), + date: Some("2026-08-02T11:00:00Z".into()), + ..Default::default() + })), + verification: Some(Box::new(models::PayloadCommitVerification { + verified: Some(true), + signature: Some("signed".into()), + ..Default::default() + })), + ..Default::default() + })), + files: Some(vec![models::CommitAffectedFiles { + filename: Some("README.md".into()), + status: Some("modified".into()), + }]), + ..Default::default() + }, + Some("main".into()), + ); + + assert_eq!(page.title, "Add details"); + assert_eq!(page.description, "Explain the change."); + assert_eq!(page.files[0].path, "README.md"); + assert_eq!( + page.metadata + .iter() + .map(|row| (row.label.as_str(), row.value.as_str())) + .collect::>(), + [ + ("Author", "Ada "), + ("Committer", "Grace"), + ("Committed", "2026-08-02"), + ("Branch", "main"), + ("Commit", "0123456789abcdef"), + ("Signature", "Verified"), + ] + ); + assert_eq!( + commit_details_page(models::Commit::default(), None) + .metadata + .last() + .unwrap() + .value, + "Unsigned" + ); + } + #[test] fn maps_heat_levels_and_labels() { let heatmap = vec![ diff --git a/crates/gitea/src/repositories.rs b/crates/gitea/src/repositories.rs index b2579e6..d61bf24 100644 --- a/crates/gitea/src/repositories.rs +++ b/crates/gitea/src/repositories.rs @@ -189,22 +189,17 @@ impl Client { }) } - pub async fn commit_files( - &self, - repository: &RepositoryId, - sha: &str, - ) -> Result> { + pub async fn commit(&self, repository: &RepositoryId, sha: &str) -> Result { apis::repository_api::repo_get_single_commit( &self.configuration(), &repository.owner, &repository.repository, sha, + Some(false), Some(true), - None, Some(true), ) .await - .map(|commit| commit.files.unwrap_or_default()) .map_err(Error::generated) } diff --git a/ios/Generated/gotcha_core.swift b/ios/Generated/gotcha_core.swift index 5e45c74..e7691de 100644 --- a/ios/Generated/gotcha_core.swift +++ b/ios/Generated/gotcha_core.swift @@ -613,9 +613,9 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func clearPullFilters() throws - func commitDiff(owner: String, repository: String, sha: String, path: String) async throws -> DiffPage + func commitDetails(owner: String, repository: String, sha: String, branch: String?) async throws -> CommitDetailsPage - func commitFiles(owner: String, repository: String, sha: String) async throws -> [FileRow] + func commitDiff(owner: String, repository: String, sha: String, path: String) async throws -> DiffPage func commits(owner: String, repository: String, branch: String?, path: String, pages: UInt32) async throws -> CommitPage @@ -793,6 +793,22 @@ open func clearPullFilters()throws {try rustCallWithError(FfiConverterTypeGotc } } +open func commitDetails(owner: String, repository: String, sha: String, branch: String?)async throws -> CommitDetailsPage { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_commit_details( + self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterString.lower(sha),FfiConverterOptionString.lower(branch) + ) + }, + pollFunc: ffi_gotcha_core_rust_future_poll_rust_buffer, + completeFunc: ffi_gotcha_core_rust_future_complete_rust_buffer, + freeFunc: ffi_gotcha_core_rust_future_free_rust_buffer, + liftFunc: FfiConverterTypeCommitDetailsPage_lift, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + open func commitDiff(owner: String, repository: String, sha: String, path: String)async throws -> DiffPage { return try await uniffiRustCallAsync( @@ -809,22 +825,6 @@ open func commitDiff(owner: String, repository: String, sha: String, path: Strin ) } -open func commitFiles(owner: String, repository: String, sha: String)async throws -> [FileRow] { - return - try await uniffiRustCallAsync( - rustFutureFunc: { - uniffi_gotcha_core_fn_method_gotchacore_commit_files( - self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterString.lower(sha) - ) - }, - pollFunc: ffi_gotcha_core_rust_future_poll_rust_buffer, - completeFunc: ffi_gotcha_core_rust_future_complete_rust_buffer, - freeFunc: ffi_gotcha_core_rust_future_free_rust_buffer, - liftFunc: FfiConverterSequenceTypeFileRow.lift, - errorHandler: FfiConverterTypeGotchaError_lift - ) -} - open func commits(owner: String, repository: String, branch: String?, path: String, pages: UInt32)async throws -> CommitPage { return try await uniffiRustCallAsync( @@ -1443,6 +1443,122 @@ public func FfiConverterTypeCommentRow_lower(_ value: CommentRow) -> RustBuffer } +public struct CommitDetailsPage: Equatable, Hashable { + public var title: String + public var description: String + public var metadata: [CommitMetadataRow] + public var files: [FileRow] + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(title: String, description: String, metadata: [CommitMetadataRow], files: [FileRow]) { + self.title = title + self.description = description + self.metadata = metadata + self.files = files + } + + + + +} + +#if compiler(>=6) +extension CommitDetailsPage: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeCommitDetailsPage: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> CommitDetailsPage { + return + try CommitDetailsPage( + title: FfiConverterString.read(from: &buf), + description: FfiConverterString.read(from: &buf), + metadata: FfiConverterSequenceTypeCommitMetadataRow.read(from: &buf), + files: FfiConverterSequenceTypeFileRow.read(from: &buf) + ) + } + + public static func write(_ value: CommitDetailsPage, into buf: inout [UInt8]) { + FfiConverterString.write(value.title, into: &buf) + FfiConverterString.write(value.description, into: &buf) + FfiConverterSequenceTypeCommitMetadataRow.write(value.metadata, into: &buf) + FfiConverterSequenceTypeFileRow.write(value.files, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeCommitDetailsPage_lift(_ buf: RustBuffer) throws -> CommitDetailsPage { + return try FfiConverterTypeCommitDetailsPage.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeCommitDetailsPage_lower(_ value: CommitDetailsPage) -> RustBuffer { + return FfiConverterTypeCommitDetailsPage.lower(value) +} + + +public struct CommitMetadataRow: Equatable, Hashable { + public var label: String + public var value: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(label: String, value: String) { + self.label = label + self.value = value + } + + + + +} + +#if compiler(>=6) +extension CommitMetadataRow: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeCommitMetadataRow: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> CommitMetadataRow { + return + try CommitMetadataRow( + label: FfiConverterString.read(from: &buf), + value: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: CommitMetadataRow, into buf: inout [UInt8]) { + FfiConverterString.write(value.label, into: &buf) + FfiConverterString.write(value.value, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeCommitMetadataRow_lift(_ buf: RustBuffer) throws -> CommitMetadataRow { + return try FfiConverterTypeCommitMetadataRow.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeCommitMetadataRow_lower(_ value: CommitMetadataRow) -> RustBuffer { + return FfiConverterTypeCommitMetadataRow.lower(value) +} + + public struct CommitPage: Equatable, Hashable { public var branches: [String] public var commits: [CommitRow] @@ -3744,6 +3860,31 @@ fileprivate struct FfiConverterSequenceTypeCommentRow: FfiConverterRustBuffer { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeCommitMetadataRow: FfiConverterRustBuffer { + typealias SwiftType = [CommitMetadataRow] + + public static func write(_ value: [CommitMetadataRow], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeCommitMetadataRow.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [CommitMetadataRow] { + let len: Int32 = try readInt(&buf) + var seq = [CommitMetadataRow]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeCommitMetadataRow.read(from: &buf)) + } + return seq + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -4147,10 +4288,10 @@ private let initializationResult: InitializationResult = { if (uniffi_gotcha_core_checksum_method_gotchacore_clear_pull_filters() != 36676) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_commit_diff() != 56887) { + if (uniffi_gotcha_core_checksum_method_gotchacore_commit_details() != 19611) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_commit_files() != 43926) { + if (uniffi_gotcha_core_checksum_method_gotchacore_commit_diff() != 56887) { return InitializationResult.apiChecksumMismatch } if (uniffi_gotcha_core_checksum_method_gotchacore_commits() != 60062) { diff --git a/ios/Generated/gotcha_coreFFI.h b/ios/Generated/gotcha_coreFFI.h index 93aa58d..72bfa28 100644 --- a/ios/Generated/gotcha_coreFFI.h +++ b/ios/Generated/gotcha_coreFFI.h @@ -284,16 +284,16 @@ void uniffi_gotcha_core_fn_method_gotchacore_clear_issue_filters(uint64_t ptr, R void uniffi_gotcha_core_fn_method_gotchacore_clear_pull_filters(uint64_t ptr, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_COMMIT_DETAILS +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_COMMIT_DETAILS +uint64_t uniffi_gotcha_core_fn_method_gotchacore_commit_details(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer sha, RustBuffer branch +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_COMMIT_DIFF #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_COMMIT_DIFF uint64_t uniffi_gotcha_core_fn_method_gotchacore_commit_diff(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer sha, RustBuffer path ); #endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_COMMIT_FILES -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_COMMIT_FILES -uint64_t uniffi_gotcha_core_fn_method_gotchacore_commit_files(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer sha -); -#endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_COMMITS #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_COMMITS uint64_t uniffi_gotcha_core_fn_method_gotchacore_commits(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer branch, RustBuffer path, uint32_t pages @@ -739,15 +739,15 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_clear_pull_filters(void ); #endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_COMMIT_DIFF -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_COMMIT_DIFF -uint16_t uniffi_gotcha_core_checksum_method_gotchacore_commit_diff(void +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_COMMIT_DETAILS +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_COMMIT_DETAILS +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_commit_details(void ); #endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_COMMIT_FILES -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_COMMIT_FILES -uint16_t uniffi_gotcha_core_checksum_method_gotchacore_commit_files(void +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_COMMIT_DIFF +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_COMMIT_DIFF +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_commit_diff(void ); #endif diff --git a/ios/Sources/ContentScreens.swift b/ios/Sources/ContentScreens.swift index 4944869..4ac1d0e 100644 --- a/ios/Sources/ContentScreens.swift +++ b/ios/Sources/ContentScreens.swift @@ -1229,7 +1229,8 @@ final class CommitsViewController: RefreshingTableViewController { context: context, owner: owner, repository: repository, - sha: row.sha + sha: row.sha, + branch: row.branchLabel ?? branch ), animated: true ) diff --git a/ios/Sources/DetailScreens.swift b/ios/Sources/DetailScreens.swift index 95137a7..885988e 100644 --- a/ios/Sources/DetailScreens.swift +++ b/ios/Sources/DetailScreens.swift @@ -398,19 +398,96 @@ private final class FileListView: UITableView, UITableViewDataSource, UITableVie } } +@MainActor +private final class CommitHeaderView: UIView { + private let stack = UIStackView() + private let titleLabel = UILabel() + private let descriptionLabel = UILabel() + private let metadataStack = UIStackView() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .systemBackground + directionalLayoutMargins = .init(top: 20, leading: 20, bottom: 20, trailing: 20) + + titleLabel.font = .preferredFont(forTextStyle: .title2) + titleLabel.adjustsFontForContentSizeCategory = true + titleLabel.numberOfLines = 0 + titleLabel.accessibilityTraits = .header + descriptionLabel.font = .preferredFont(forTextStyle: .body) + descriptionLabel.adjustsFontForContentSizeCategory = true + descriptionLabel.numberOfLines = 0 + + metadataStack.axis = .vertical + metadataStack.spacing = 10 + stack.axis = .vertical + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + stack.addArrangedSubview(titleLabel) + stack.addArrangedSubview(descriptionLabel) + stack.addArrangedSubview(metadataStack) + addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + stack.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), + stack.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + func configure(_ page: CommitDetailsPage) { + titleLabel.text = page.title + descriptionLabel.text = page.description + descriptionLabel.isHidden = page.description.isEmpty + metadataStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + for metadata in page.metadata { + let isCommit = metadata.label == "Commit" + let label = UILabel() + label.text = metadata.label.uppercased() + label.font = .preferredFont(forTextStyle: .caption2) + label.adjustsFontForContentSizeCategory = true + label.textColor = .secondaryLabel + + let value = UILabel() + value.text = metadata.value + value.font = isCommit + ? UIFontMetrics(forTextStyle: .subheadline).scaledFont( + for: .monospacedSystemFont(ofSize: 15, weight: .regular) + ) + : .preferredFont(forTextStyle: .subheadline) + value.adjustsFontForContentSizeCategory = true + value.numberOfLines = 0 + value.lineBreakMode = isCommit ? .byCharWrapping : .byWordWrapping + + let row = UIStackView(arrangedSubviews: [label, value]) + row.axis = .vertical + row.spacing = 2 + row.isAccessibilityElement = true + row.accessibilityLabel = "\(metadata.label): \(metadata.value)" + metadataStack.addArrangedSubview(row) + } + } +} + @MainActor final class FilesViewController: RefreshingTableViewController { private let context: AppContext private let owner: String private let repository: String private let sha: String - private var rows: [FileRow] = [] + private let branch: String? + private let commitHeader = CommitHeaderView() + private var page: CommitDetailsPage? - init(context: AppContext, owner: String, repository: String, sha: String) { + init(context: AppContext, owner: String, repository: String, sha: String, branch: String? = nil) { self.context = context self.owner = owner self.repository = repository self.sha = sha + self.branch = branch super.init() title = "Changed Files" } @@ -423,18 +500,35 @@ final class FilesViewController: RefreshingTableViewController { loadContent(refreshing: false) } + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + guard let header = tableView.tableHeaderView else { return } + let size = header.systemLayoutSizeFitting( + CGSize(width: tableView.bounds.width, height: 0), + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel + ) + guard header.frame.height != size.height else { return } + header.frame.size.height = size.height + tableView.tableHeaderView = header + } + override func loadContent(refreshing: Bool) { beginLoading(refreshing: refreshing) loadingTask?.cancel() loadingTask = Task { do { - rows = try await context.core.commitFiles( + let page = try await context.core.commitDetails( owner: owner, repository: repository, - sha: sha + sha: sha, + branch: branch ) + self.page = page + commitHeader.configure(page) + tableView.tableHeaderView = commitHeader tableView.reloadData() - tableView.backgroundView = rows.isEmpty + tableView.backgroundView = page.files.isEmpty ? EmptyBackgroundView(title: "No changed files", detail: "This commit does not contain file changes.") : nil } catch { @@ -445,7 +539,7 @@ final class FilesViewController: RefreshingTableViewController { } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - rows.count + page?.files.count ?? 0 } override func tableView( @@ -454,7 +548,7 @@ final class FilesViewController: RefreshingTableViewController { ) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "file") ?? UITableViewCell(style: .subtitle, reuseIdentifier: "file") - let row = rows[indexPath.row] + let row = page!.files[indexPath.row] configureTextCell(cell, title: row.path, detail: row.status) cell.accessoryType = .disclosureIndicator return cell @@ -462,7 +556,7 @@ final class FilesViewController: RefreshingTableViewController { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - let row = rows[indexPath.row] + guard let row = page?.files[indexPath.row] else { return } navigationController?.pushViewController( DiffViewController( context: context, @@ -633,7 +727,8 @@ final class RepositoryDirectoryViewController: RefreshingTableViewController { context: context, owner: owner, repository: repository, - sha: commit.sha + sha: commit.sha, + branch: commit.branchLabel ), animated: true ) @@ -780,7 +875,8 @@ final class RepositoryHistoryViewController: RefreshingTableViewController { context: context, owner: owner, repository: repository, - sha: commit.sha + sha: commit.sha, + branch: commit.branchLabel ), animated: true )