import Highlighter import QuickLook import SwiftUI import UIKit @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 } } 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 enum Mode: String { case preview = "Preview" case source = "Source" case content = "Content" case history = "History" } 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? private var modes: [Mode] = [] 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() modes = page.kind == .markdown ? [.preview, .source, .history] : [.content, .history] for (index, mode) in modes.enumerated() { modeControl.insertSegment(withTitle: mode.rawValue, 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, modes.indices.contains(sender.selectedSegmentIndex) else { return } showContent(page, mode: modes[sender.selectedSegmentIndex]) } private func showContent(_ page: RepositoryFilePage, mode: Mode) { switch mode { case .preview: showMarkdownPreview(page.text) case .source: showSource(page.text) case .content: if page.kind == .source { showSource(page.text) } else { do { try showQuickLook(page.data, name: page.name) } catch { show(error: error) } } case .history: showHistory() } } 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)) } }