import Highlighter import QuickLook import SwiftUI import UIKit @MainActor class MarkdownPageViewController: UIViewController, UIScrollViewDelegate { let context: AppContext let scrollView = UIScrollView() let stack = UIStackView() private let spinner = UIActivityIndicatorView(style: .medium) var loadingTask: Task? private lazy var moreButton = UIButton( configuration: .plain(), primaryAction: UIAction { [weak self] _ in self?.requestMoreContent() } ) private var hasMoreContent = false private var loadingMore = false init(context: AppContext) { self.context = context super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemGroupedBackground scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.alwaysBounceVertical = true scrollView.delegate = self scrollView.refreshControl = UIRefreshControl() scrollView.refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged) view.addSubview(scrollView) stack.axis = .vertical stack.spacing = 12 stack.translatesAutoresizingMaskIntoConstraints = false scrollView.addSubview(stack) NSLayoutConstraint.activate([ scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), scrollView.topAnchor.constraint(equalTo: view.topAnchor), scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 18), stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -18), stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 18), stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -18), stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -36), ]) } deinit { loadingTask?.cancel() } func loadContent(refreshing: Bool) {} func loadMoreContent() {} func beginLoading(refreshing: Bool) { if !refreshing { beginNavigationLoading(spinner) } } func endLoading() { endNavigationLoading(spinner) scrollView.refreshControl?.endRefreshing() } func replaceContent(_ views: [UIView]) { stack.arrangedSubviews.forEach { $0.removeFromSuperview() } views.forEach(stack.addArrangedSubview) } func resetPagination() { hasMoreContent = false loadingMore = false stack.removeArrangedSubview(moreButton) moreButton.removeFromSuperview() } func finishPagination(hasMore: Bool) { hasMoreContent = hasMore loadingMore = false guard hasMore else { return } var configuration = moreButton.configuration configuration?.title = "Pull up or tap to load more" configuration?.showsActivityIndicator = false moreButton.configuration = configuration moreButton.accessibilityLabel = "Load more results" if moreButton.superview !== stack { stack.addArrangedSubview(moreButton) } if moreButton.constraints.isEmpty { moreButton.heightAnchor.constraint(equalToConstant: 50).isActive = true } } func failPagination() { loadingMore = false finishPagination(hasMore: hasMoreContent) } func scrollViewDidScroll(_ scrollView: UIScrollView) { guard scrollView.isDragging, hasMoreContent, !loadingMore else { return } let bottom = max( -scrollView.adjustedContentInset.top, scrollView.contentSize.height + scrollView.adjustedContentInset.bottom - scrollView.bounds.height ) if scrollView.contentOffset.y > bottom + 60 { requestMoreContent() } } private func requestMoreContent() { guard hasMoreContent, !loadingMore else { return } loadingMore = true var configuration = moreButton.configuration configuration?.title = "Loading more…" configuration?.showsActivityIndicator = true moreButton.configuration = configuration loadMoreContent() } @objc private func refreshRequested() { loadContent(refreshing: true) } } @MainActor final class IssueViewController: MarkdownPageViewController { private let owner: String private let repository: String private let number: Int64 private var page: IssuePage? private var currentPage: UInt32 = 0 init(context: AppContext, owner: String, repository: String, number: Int64) { self.owner = owner self.repository = repository self.number = number super.init(context: context) title = "Issue" } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } override func viewDidLoad() { super.viewDidLoad() let addComment = UIBarButtonItem( image: context.symbol("plus.bubble"), style: .plain, target: self, action: #selector(addComment) ) addComment.accessibilityLabel = "Add comment" let editIssue = UIBarButtonItem( image: context.symbol("pencil"), style: .plain, target: self, action: #selector(editIssue) ) editIssue.accessibilityLabel = "Edit issue" navigationItem.rightBarButtonItems = [addComment, editIssue] loadContent(refreshing: false) } @objc private func addComment() { presentCommentEditor() } private func editComment(_ comment: CommentRow) { presentCommentEditor(comment) } private func presentCommentEditor(_ comment: CommentRow? = nil) { let editor = CommentEditorViewController( context: context, owner: owner, repository: repository, number: number, comment: comment ) { [weak self] in guard let self else { return } self.dismiss(animated: true) { self.loadContent(refreshing: false) } } present(UINavigationController(rootViewController: editor), animated: true) } @objc private func editIssue() { let editor = IssueEditorViewController( context: context, owner: owner, repository: repository, number: number ) { [weak self] _ in guard let self else { return } self.dismiss(animated: true) { self.loadContent(refreshing: false) } } present(UINavigationController(rootViewController: editor), animated: true) } override func loadContent(refreshing: Bool) { loadPage(1, refreshing: refreshing) } override func loadMoreContent() { loadPage(currentPage + 1, refreshing: false) } private func loadPage(_ requestedPage: UInt32, refreshing: Bool) { if requestedPage == 1 { resetPagination() beginLoading(refreshing: refreshing) } loadingTask?.cancel() loadingTask = Task { do { let result = try await context.core.issue( owner: owner, repository: repository, number: number, page: requestedPage ) if requestedPage == 1 { page = result } else { page?.comments.append(contentsOf: result.comments) page?.hasMore = result.hasMore } currentPage = requestedPage if let page { replaceContent(detailViews( title: page.title, state: page.state, stateSubject: "issue", meta: page.meta, body: page.body, comments: page.comments, editComment: { [weak self] comment in self?.editComment(comment) }, milestone: page.milestone, labels: page.labels )) finishPagination(hasMore: page.hasMore) } } catch { if !Task.isCancelled { show(error: error) failPagination() } } if requestedPage == 1 { endLoading() } } } } @MainActor final class PullViewController: MarkdownPageViewController { private let owner: String private let repository: String private let number: Int64 private var page: PullPage? private var currentPage: UInt32 = 0 init(context: AppContext, owner: String, repository: String, number: Int64) { self.owner = owner self.repository = repository self.number = number super.init(context: context) title = "Pull Request" } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } override func viewDidLoad() { super.viewDidLoad() loadContent(refreshing: false) } override func loadContent(refreshing: Bool) { loadPage(1, refreshing: refreshing) } override func loadMoreContent() { loadPage(currentPage + 1, refreshing: false) } private func loadPage(_ requestedPage: UInt32, refreshing: Bool) { if requestedPage == 1 { resetPagination() beginLoading(refreshing: refreshing) } loadingTask?.cancel() loadingTask = Task { do { let result = try await context.core.pull( owner: owner, repository: repository, number: number, page: requestedPage ) if requestedPage == 1 { page = result } else { page?.files.append(contentsOf: result.files) page?.comments.append(contentsOf: result.comments) page?.hasMore = result.hasMore } currentPage = requestedPage if let page { var views = detailViews( title: page.title, state: page.state, stateSubject: "pull request", meta: page.meta, body: page.body, comments: [] ) if !page.files.isEmpty { views.append(sectionLabel(page.filesRef)) views.append(FileListView(rows: page.files) { [weak self] file in guard let self else { return } self.navigationController?.pushViewController( DiffViewController( context: self.context, source: .pull( owner: self.owner, repository: self.repository, number: self.number, path: file.path ) ), animated: true ) }) } views.append(contentsOf: commentViews(page.comments)) replaceContent(views) finishPagination(hasMore: page.hasMore) } } catch { if !Task.isCancelled { show(error: error) failPagination() } } if requestedPage == 1 { endLoading() } } } } private final class FileListView: UITableView, UITableViewDataSource, UITableViewDelegate { private let rows: [FileRow] private let select: (FileRow) -> Void private var contentHeight: NSLayoutConstraint! init(rows: [FileRow], select: @escaping (FileRow) -> Void) { self.rows = rows self.select = select super.init(frame: .zero, style: .plain) translatesAutoresizingMaskIntoConstraints = false dataSource = self delegate = self isScrollEnabled = false rowHeight = UITableView.automaticDimension estimatedRowHeight = 55 separatorInset = .zero backgroundColor = .systemGroupedBackground contentHeight = heightAnchor.constraint(equalToConstant: CGFloat(rows.count) * estimatedRowHeight) contentHeight.isActive = true } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } override func layoutSubviews() { super.layoutSubviews() if abs(contentHeight.constant - contentSize.height) > 0.5 { contentHeight.constant = contentSize.height } } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { rows.count } func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "file") ?? UITableViewCell(style: .subtitle, reuseIdentifier: "file") let row = rows[indexPath.row] configureTextCell(cell, title: row.path, detail: row.status) cell.accessoryType = .disclosureIndicator return cell } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) select(rows[indexPath.row]) } } @MainActor private final class CommitHeaderView: UIView { private let stack = UIStackView() private let titleLabel = UILabel() private var descriptionView: UIView? 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 metadataStack.axis = .vertical metadataStack.spacing = 10 stack.axis = .vertical stack.spacing = 12 stack.translatesAutoresizingMaskIntoConstraints = false stack.addArrangedSubview(titleLabel) 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 descriptionView?.removeFromSuperview() if !page.description.isEmpty { let view = markdownView(page.description) stack.insertArrangedSubview(view, at: 1) descriptionView = view } 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 let branch: String? private let commitHeader = CommitHeaderView() private var page: CommitDetailsPage? 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" } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } override func viewDidLoad() { super.viewDidLoad() 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 { let page = try await context.core.commitDetails( owner: owner, repository: repository, sha: sha, branch: branch ) self.page = page commitHeader.configure(page) tableView.tableHeaderView = commitHeader tableView.reloadData() tableView.backgroundView = page.files.isEmpty ? EmptyBackgroundView(title: "No changed files", detail: "This commit does not contain file changes.") : nil } catch { if !Task.isCancelled { show(error: error) } } endLoading() } } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { page?.files.count ?? 0 } override func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "file") ?? UITableViewCell(style: .subtitle, reuseIdentifier: "file") let row = page!.files[indexPath.row] configureTextCell(cell, title: row.path, detail: row.status) cell.accessoryType = .disclosureIndicator return cell } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) guard let row = page?.files[indexPath.row] else { return } navigationController?.pushViewController( DiffViewController( context: context, source: .commit( owner: owner, repository: repository, sha: sha, path: row.path ) ), animated: true ) } } @MainActor final class RepositoryDirectoryViewController: RefreshingTableViewController { private enum Mode: Int { case files, history } private let context: AppContext private let owner: String private let repository: String private let path: String private var mode = Mode.files private var rows: [RepositoryContentRow] = [] private var history: CommitPage? private var currentPage: UInt32 = 0 private var loadedFiles = false init(context: AppContext, owner: String, repository: String, path: String, name: String) { self.context = context self.owner = owner self.repository = repository self.path = path super.init() title = name } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } override func viewDidLoad() { super.viewDidLoad() tableView.register(CommitCell.self, forCellReuseIdentifier: "commit") navigationItem.prompt = title let modeControl = UISegmentedControl(items: ["Files", "History"]) modeControl.selectedSegmentIndex = mode.rawValue modeControl.addTarget(self, action: #selector(modeChanged(_:)), for: .valueChanged) modeControl.accessibilityLabel = "Directory view" navigationItem.titleView = modeControl loadContent(refreshing: false) } override func loadContent(refreshing: Bool) { switch mode { case .files: loadFiles(refreshing: refreshing) case .history: loadHistory(page: 1, refreshing: refreshing) } } override func loadMoreContent() { guard mode == .history else { return } loadHistory(page: currentPage + 1, refreshing: false) } private func loadFiles(refreshing: Bool) { resetPagination() beginLoading(refreshing: refreshing) loadingTask?.cancel() loadingTask = Task { do { let rows = try await context.core.repositoryContents( owner: owner, repository: repository, path: path ) guard !Task.isCancelled, mode == .files else { endLoading() return } self.rows = rows loadedFiles = true tableView.reloadData() updateEmptyView() } catch { if !Task.isCancelled { show(error: error) } } endLoading() } } private func loadHistory(page requestedPage: UInt32, refreshing: Bool) { if requestedPage == 1 { resetPagination() beginLoading(refreshing: refreshing) } loadingTask?.cancel() loadingTask = Task { do { let history = try await context.core.commits( owner: owner, repository: repository, branch: nil, path: path, pages: requestedPage ) guard !Task.isCancelled, mode == .history else { if requestedPage == 1 { endLoading() } return } self.history = history currentPage = requestedPage finishPagination(hasMore: history.hasMore) tableView.reloadData() updateEmptyView() } catch { if !Task.isCancelled { show(error: error) failPagination() } } if requestedPage == 1 { endLoading() } } } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { mode == .files ? rows.count : history?.commits.count ?? 0 } override func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) -> UITableViewCell { switch mode { case .files: let cell = tableView.dequeueReusableCell(withIdentifier: "repository-content") ?? UITableViewCell(style: .subtitle, reuseIdentifier: "repository-content") configureRepositoryContentCell(cell, row: rows[indexPath.row]) return cell case .history: let cell = tableView.dequeueReusableCell(withIdentifier: "commit", for: indexPath) as! CommitCell if let history { cell.configure(history.commits[indexPath.row], laneCount: history.laneCount) } return cell } } override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { mode == .history ? 86 : UITableView.automaticDimension } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) switch mode { case .files: showRepositoryContent( rows[indexPath.row], context: context, owner: owner, repository: repository, navigationController: navigationController ) case .history: guard let commit = history?.commits[indexPath.row] else { return } navigationController?.pushViewController( FilesViewController( context: context, owner: owner, repository: repository, sha: commit.sha, branch: commit.branchLabel ), animated: true ) } } @objc private func modeChanged(_ sender: UISegmentedControl) { guard let mode = Mode(rawValue: sender.selectedSegmentIndex), mode != self.mode else { return } loadingTask?.cancel() endLoading() self.mode = mode resetPagination() tableView.backgroundView = nil tableView.reloadData() switch mode { case .files: if loadedFiles { updateEmptyView() } else { loadFiles(refreshing: false) } case .history: if let history { finishPagination(hasMore: history.hasMore) updateEmptyView() } else { loadHistory(page: 1, refreshing: false) } } } private func updateEmptyView() { switch mode { case .files: tableView.backgroundView = loadedFiles && rows.isEmpty ? EmptyBackgroundView(title: "Empty folder", detail: "This folder does not contain any files.") : nil case .history: tableView.backgroundView = history?.commits.isEmpty == true ? EmptyBackgroundView(title: "No commits", detail: "No commits affect this folder.") : nil } } } @MainActor final class RepositoryHistoryViewController: RefreshingTableViewController { private let context: AppContext private let owner: String private let repository: String private let path: String private let emptyDetail: String private let loadingIndicator = UIActivityIndicatorView(style: .medium) private var page: CommitPage? private var currentPage: UInt32 = 0 init( context: AppContext, owner: String, repository: String, path: String, emptyDetail: String ) { self.context = context self.owner = owner self.repository = repository self.path = path self.emptyDetail = emptyDetail super.init() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } override func viewDidLoad() { super.viewDidLoad() tableView.register(CommitCell.self, forCellReuseIdentifier: "commit") loadContent(refreshing: false) } override func loadContent(refreshing: Bool) { loadPage(1, refreshing: refreshing) } override func loadMoreContent() { loadPage(currentPage + 1, refreshing: false) } private func loadPage(_ requestedPage: UInt32, refreshing: Bool) { if requestedPage == 1 { resetPagination() if !refreshing { loadingIndicator.startAnimating() tableView.backgroundView = loadingIndicator } } loadingTask?.cancel() loadingTask = Task { do { let page = try await context.core.commits( owner: owner, repository: repository, branch: nil, path: path, pages: requestedPage ) guard !Task.isCancelled else { return } self.page = page currentPage = requestedPage finishPagination(hasMore: page.hasMore) tableView.reloadData() updateEmptyView() } catch { if !Task.isCancelled { show(error: error) failPagination() } } if requestedPage == 1 { loadingIndicator.stopAnimating() refreshControl?.endRefreshing() } } } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { page?.commits.count ?? 0 } override func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "commit", for: indexPath) as! CommitCell if let page { cell.configure(page.commits[indexPath.row], laneCount: page.laneCount) } return cell } override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 86 } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) guard let commit = page?.commits[indexPath.row] else { return } navigationController?.pushViewController( FilesViewController( context: context, owner: owner, repository: repository, sha: commit.sha, branch: commit.branchLabel ), animated: true ) } private func updateEmptyView() { tableView.backgroundView = page?.commits.isEmpty == true ? EmptyBackgroundView(title: "No commits", detail: emptyDetail) : nil } } private final class CodeScrollView: UIScrollView { private let textView = UITextView(frame: .zero) override init(frame: CGRect) { super.init(frame: frame) translatesAutoresizingMaskIntoConstraints = false alwaysBounceVertical = true alwaysBounceHorizontal = true isDirectionalLockEnabled = true showsHorizontalScrollIndicator = true contentInsetAdjustmentBehavior = .never textView.textContainer.lineBreakMode = .byClipping textView.textContainer.widthTracksTextView = false textView.isEditable = false textView.isScrollEnabled = false textView.isSelectable = true addSubview(textView) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } func display(_ text: NSAttributedString) { let text = NSMutableAttributedString(attributedString: text) let paragraph = NSMutableParagraphStyle() paragraph.lineBreakMode = .byClipping text.addAttribute(.paragraphStyle, value: paragraph, range: NSRange(location: 0, length: text.length)) textView.textStorage.setAttributedString(text) setNeedsLayout() } override func layoutSubviews() { super.layoutSubviews() textView.textContainer.size = CGSize( width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude ) textView.layoutManager.ensureLayout(for: textView.textContainer) let textSize = textView.layoutManager.usedRect(for: textView.textContainer).size let size = CGSize( width: max( bounds.width, ceil(textSize.width + textView.textContainerInset.left + textView.textContainerInset.right) ), height: max( bounds.height, ceil(textSize.height + textView.textContainerInset.top + textView.textContainerInset.bottom) ) ) textView.frame = CGRect(origin: .zero, size: size) contentSize = size } } @MainActor final class RepositoryFileViewController: UIViewController, QLPreviewControllerDataSource { private let context: AppContext private let owner: String private let repository: String private let path: String private let spinner = UIActivityIndicatorView(style: .medium) private let modeControl = UISegmentedControl() private var loadingTask: Task? private var page: RepositoryFilePage? private var previewURL: URL? private var fileLanguage: String? private var historyController: RepositoryHistoryViewController? private var displayedController: UIViewController? private var displayedView: UIView? init(context: AppContext, owner: String, repository: String, path: String, name: String) { self.context = context self.owner = owner self.repository = repository self.path = path super.init(nibName: nil, bundle: nil) title = name } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemBackground beginNavigationLoading(spinner) loadingTask = Task { do { let page = try await context.core.repositoryFile( owner: owner, repository: repository, path: path ) guard !Task.isCancelled else { return } self.page = page title = page.name fileLanguage = page.language.isEmpty ? nil : page.language configureModes(for: page) } catch { if !Task.isCancelled { show(error: error) } } endNavigationLoading(spinner) } } deinit { loadingTask?.cancel() if let previewURL { try? FileManager.default.removeItem(at: previewURL) } } func numberOfPreviewItems(in controller: QLPreviewController) -> Int { previewURL == nil ? 0 : 1 } func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { previewURL! as NSURL } private func configureModes(for page: RepositoryFilePage) { navigationItem.prompt = title modeControl.removeAllSegments() let modes = page.kind == "markdown" ? ["Preview", "Source", "History"] : ["Content", "History"] for (index, mode) in modes.enumerated() { modeControl.insertSegment(withTitle: mode, at: index, animated: false) } modeControl.selectedSegmentIndex = 0 modeControl.addTarget(self, action: #selector(fileModeChanged(_:)), for: .valueChanged) modeControl.accessibilityLabel = "File view" navigationItem.titleView = modeControl showContent(page, mode: modes[0]) } @objc private func fileModeChanged(_ sender: UISegmentedControl) { guard let page, let mode = sender.titleForSegment(at: sender.selectedSegmentIndex) else { return } showContent(page, mode: mode) } private func showContent(_ page: RepositoryFilePage, mode: String) { switch mode { case "Preview": showMarkdownPreview(page.text) case "Source": showSource(page.text) case "Content" where page.kind == "source": showSource(page.text) case "Content": do { try showQuickLook(page.data, name: page.name) } catch { show(error: error) } case "History": showHistory() default: break } } private func showHistory() { removeDisplayedView() let history = historyController ?? RepositoryHistoryViewController( context: context, owner: owner, repository: repository, path: path, emptyDetail: "No commits affect this file." ) historyController = history addChild(history) history.view.frame = view.bounds history.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] displayedController = history displayedView = history.view view.addSubview(history.view) history.didMove(toParent: self) } private func showMarkdownPreview(_ source: String) { removeDisplayedView() let preview = UIHostingController(rootView: RepositoryMarkdownPreview(source: source)) addChild(preview) preview.view.frame = view.bounds preview.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] displayedController = preview displayedView = preview.view view.addSubview(preview.view) preview.didMove(toParent: self) } private func showSource(_ source: String) { removeDisplayedView() let highlighter = Highlighter() highlighter?.setTheme( traitCollection.userInterfaceStyle == .dark ? "atom-one-dark" : "atom-one-light" ) highlighter?.theme.setCodeFont(.monospacedSystemFont(ofSize: 13, weight: .regular)) let highlighted = highlighter?.highlight(source, as: fileLanguage) ?? NSAttributedString(string: source, attributes: [ .font: UIFont.monospacedSystemFont(ofSize: 13, weight: .regular), .foregroundColor: UIColor.label, ]) let codeView = CodeScrollView() codeView.display(highlighted) displayedView = codeView view.addSubview(codeView) NSLayoutConstraint.activate([ codeView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), codeView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), codeView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), codeView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), ]) } private func removeDisplayedView() { displayedController?.willMove(toParent: nil) displayedView?.removeFromSuperview() displayedController?.removeFromParent() displayedController = nil displayedView = nil } private func showQuickLook(_ data: Data, name: String) throws { removeDisplayedView() if previewURL == nil { let url = FileManager.default.temporaryDirectory .appendingPathComponent("\(UUID().uuidString)-\(name)") try data.write(to: url, options: .atomic) previewURL = url } let preview = QLPreviewController() preview.dataSource = self addChild(preview) preview.view.frame = view.bounds preview.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] displayedController = preview displayedView = preview.view view.addSubview(preview.view) preview.didMove(toParent: self) } } struct RepositoryMarkdownPreview: View { let source: String var body: some View { ScrollView { MarkdownContent(source: source) .padding() } .background(Color(uiColor: .systemBackground)) } } enum DiffSource { case commit(owner: String, repository: String, sha: String, path: String) case pull(owner: String, repository: String, number: Int64, path: String) } @MainActor final class DiffViewController: UIViewController { private let context: AppContext private let source: DiffSource private let codeView = CodeScrollView() private let spinner = UIActivityIndicatorView(style: .medium) private var loadingTask: Task? init(context: AppContext, source: DiffSource) { self.context = context self.source = source super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemBackground codeView.refreshControl = UIRefreshControl() codeView.refreshControl?.addTarget(self, action: #selector(reload), for: .valueChanged) view.addSubview(codeView) NSLayoutConstraint.activate([ codeView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), codeView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), codeView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), codeView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), ]) beginNavigationLoading(spinner) reload() } deinit { loadingTask?.cancel() } @objc private func reload() { loadingTask?.cancel() loadingTask = Task { do { let page: DiffPage switch source { case let .commit(owner, repository, sha, path): page = try await context.core.commitDiff( owner: owner, repository: repository, sha: sha, path: path ) case let .pull(owner, repository, number, path): page = try await context.core.pullDiff( owner: owner, repository: repository, number: number, path: path ) } title = page.title codeView.display(diffText(page)) } catch { if !Task.isCancelled { show(error: error) } } endNavigationLoading(spinner) codeView.refreshControl?.endRefreshing() } } private func diffText(_ page: DiffPage) -> NSAttributedString { let output = NSMutableAttributedString() let font = UIFont.monospacedSystemFont(ofSize: 12, weight: .regular) for line in page.lines { let text = String(format: "%4@ %4@ %@\n", line.oldNumber, line.newNumber, line.text) let color: UIColor switch line.kind { case "addition": color = UIColor.systemGreen.withAlphaComponent(0.16) case "removal": color = UIColor.systemRed.withAlphaComponent(0.16) case "hunk": color = UIColor.systemBlue.withAlphaComponent(0.14) case "header": color = UIColor.systemGray.withAlphaComponent(0.14) default: color = .clear } output.append(NSAttributedString(string: text, attributes: [ .font: font, .foregroundColor: UIColor.label, .backgroundColor: color, ])) } return output } } @MainActor private func detailViews( title: String, state: String? = nil, stateSubject: String = "", meta: String, body: String, comments: [CommentRow], editComment: ((CommentRow) -> Void)? = nil, milestone: String = "", labels: [LabelRow] = [] ) -> [UIView] { let titleLabel = UILabel() titleLabel.text = title titleLabel.font = .preferredFont(forTextStyle: .title1) titleLabel.numberOfLines = 0 let metaLabel = UILabel() metaLabel.text = meta metaLabel.font = .preferredFont(forTextStyle: .subheadline) metaLabel.textColor = .secondaryLabel metaLabel.numberOfLines = 0 let bodyView = markdownView(body) var views: [UIView] = [ state.map { stateTitle(titleLabel, state: $0, subject: stateSubject) } ?? titleLabel, metaLabel, ] if !milestone.isEmpty { let milestoneLabel = UILabel() milestoneLabel.attributedText = symbolText( "flag.fill", text: milestone, font: .preferredFont(forTextStyle: .subheadline), color: .secondaryLabel ) milestoneLabel.accessibilityLabel = "Milestone \(milestone)" views.append(milestoneLabel) } if !labels.isEmpty { views.append(IssueLabelsView(labels)) } return views + [separator(), bodyView] + commentViews(comments, editComment: editComment) } private func stateTitle(_ titleLabel: UILabel, state: String, subject: String) -> UIView { let icon = UIImageView() configureOpenClosedStateIcon(icon, state: state, subject: subject, textStyle: .title1) let stack = UIStackView(arrangedSubviews: [icon, titleLabel]) stack.axis = .horizontal stack.alignment = .firstBaseline stack.spacing = 8 return stack } @MainActor private func commentViews( _ comments: [CommentRow], editComment: ((CommentRow) -> Void)? = nil ) -> [UIView] { guard !comments.isEmpty else { return [] } var views: [UIView] = [sectionLabel("Comments")] for comment in comments { let author = UILabel() author.text = comment.author author.font = .preferredFont(forTextStyle: .headline) var headerViews: [UIView] = [author, UIView()] if comment.canEdit, let editComment { var configuration = UIButton.Configuration.plain() configuration.image = UIImage(systemName: "pencil") configuration.contentInsets = .zero let button = UIButton( configuration: configuration, primaryAction: UIAction { _ in editComment(comment) } ) button.accessibilityLabel = "Edit comment by \(comment.author)" button.widthAnchor.constraint(equalToConstant: 44).isActive = true button.heightAnchor.constraint(equalToConstant: 44).isActive = true headerViews.append(button) } let header = UIStackView(arrangedSubviews: headerViews) header.alignment = .center let date = UILabel() date.text = comment.meta date.font = .preferredFont(forTextStyle: .caption1) date.textColor = .tertiaryLabel let stack = UIStackView(arrangedSubviews: [header, markdownView(comment.body), date, separator()]) stack.axis = .vertical stack.spacing = 6 views.append(stack) } return views } private func sectionLabel(_ text: String) -> UILabel { let label = UILabel() label.text = text label.font = .preferredFont(forTextStyle: .headline) return label } private func separator() -> UIView { let line = UIView() line.backgroundColor = .separator line.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true return line }