Show diffs without word wrapping

This commit is contained in:
Georg Bauer
2026-07-31 17:14:34 +02:00
parent e39695fc60
commit 5b29e18942
2 changed files with 74 additions and 84 deletions

View File

@@ -200,7 +200,8 @@ xcrun simctl launch booted de.rfc1437.gotcha
- [ ] Open a commit and verify Changed Files paths and statuses. - [ ] Open a commit and verify Changed Files paths and statuses.
- [ ] Open a changed file and verify the diff title, old/new line numbers, - [ ] Open a changed file and verify the diff title, old/new line numbers,
monospaced text, addition/removal/hunk colors, vertical scrolling, and monospaced text, addition/removal/hunk colors, vertical scrolling, and
horizontal scrolling for long lines. source-style horizontal scrolling for long lines without word wrapping,
clipped or jumping text, or content hidden beneath the navigation bar.
- [ ] Long-press diff text and verify normal selection and copying. - [ ] Long-press diff text and verify normal selection and copying.
## Repository files ## Repository files

View File

@@ -738,6 +738,60 @@ final class RepositoryHistoryViewController: RefreshingTableViewController {
} }
} }
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 @MainActor
final class RepositoryFileViewController: UIViewController, QLPreviewControllerDataSource { final class RepositoryFileViewController: UIViewController, QLPreviewControllerDataSource {
private let context: AppContext private let context: AppContext
@@ -753,8 +807,6 @@ final class RepositoryFileViewController: UIViewController, QLPreviewControllerD
private var historyController: RepositoryHistoryViewController? private var historyController: RepositoryHistoryViewController?
private var displayedController: UIViewController? private var displayedController: UIViewController?
private var displayedView: UIView? private var displayedView: UIView?
private weak var sourceScrollView: UIScrollView?
private weak var sourceTextView: UITextView?
init(context: AppContext, owner: String, repository: String, path: String, name: String) { init(context: AppContext, owner: String, repository: String, path: String, name: String) {
self.context = context self.context = context
@@ -791,11 +843,6 @@ final class RepositoryFileViewController: UIViewController, QLPreviewControllerD
} }
} }
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
updateSourceLayout()
}
deinit { deinit {
loadingTask?.cancel() loadingTask?.cancel()
if let previewURL { try? FileManager.default.removeItem(at: previewURL) } if let previewURL { try? FileManager.default.removeItem(at: previewURL) }
@@ -889,41 +936,16 @@ final class RepositoryFileViewController: UIViewController, QLPreviewControllerD
.font: UIFont.monospacedSystemFont(ofSize: 13, weight: .regular), .font: UIFont.monospacedSystemFont(ofSize: 13, weight: .regular),
.foregroundColor: UIColor.label, .foregroundColor: UIColor.label,
]) ])
let text = NSMutableAttributedString(attributedString: highlighted) let codeView = CodeScrollView()
let paragraph = NSMutableParagraphStyle() codeView.display(highlighted)
paragraph.lineBreakMode = .byClipping displayedView = codeView
text.addAttribute(.paragraphStyle, value: paragraph, range: NSRange(location: 0, length: text.length)) view.addSubview(codeView)
let scrollView = UIScrollView()
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.alwaysBounceHorizontal = true
scrollView.isDirectionalLockEnabled = true
scrollView.showsHorizontalScrollIndicator = true
scrollView.contentInsetAdjustmentBehavior = .never
let textView = UITextView(frame: .zero)
textView.textContainer.size = CGSize(
width: CGFloat.greatestFiniteMagnitude,
height: CGFloat.greatestFiniteMagnitude
)
textView.textContainer.lineBreakMode = .byClipping
textView.textContainer.widthTracksTextView = false
textView.textStorage.setAttributedString(text)
textView.isEditable = false
textView.isScrollEnabled = false
textView.isSelectable = true
sourceScrollView = scrollView
sourceTextView = textView
scrollView.addSubview(textView)
displayedView = scrollView
view.addSubview(scrollView)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
scrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), codeView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), codeView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), codeView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), codeView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
]) ])
updateSourceLayout()
} }
private func removeDisplayedView() { private func removeDisplayedView() {
@@ -932,31 +954,6 @@ final class RepositoryFileViewController: UIViewController, QLPreviewControllerD
displayedController?.removeFromParent() displayedController?.removeFromParent()
displayedController = nil displayedController = nil
displayedView = nil displayedView = nil
sourceScrollView = nil
sourceTextView = nil
}
private func updateSourceLayout() {
guard let scrollView = sourceScrollView, let textView = sourceTextView else { return }
textView.textContainer.widthTracksTextView = false
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 contentSize = CGSize(
width: max(
scrollView.bounds.width,
ceil(textSize.width + textView.textContainerInset.left + textView.textContainerInset.right)
),
height: max(
scrollView.bounds.height,
ceil(textSize.height + textView.textContainerInset.top + textView.textContainerInset.bottom)
)
)
textView.frame = CGRect(origin: .zero, size: contentSize)
scrollView.contentSize = contentSize
} }
private func showQuickLook(_ data: Data, name: String) throws { private func showQuickLook(_ data: Data, name: String) throws {
@@ -1002,7 +999,7 @@ enum DiffSource {
final class DiffViewController: UIViewController { final class DiffViewController: UIViewController {
private let context: AppContext private let context: AppContext
private let source: DiffSource private let source: DiffSource
private let textView = UITextView() private let codeView = CodeScrollView()
private let spinner = UIActivityIndicatorView(style: .medium) private let spinner = UIActivityIndicatorView(style: .medium)
private var loadingTask: Task<Void, Never>? private var loadingTask: Task<Void, Never>?
@@ -1018,22 +1015,14 @@ final class DiffViewController: UIViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
view.backgroundColor = .systemBackground view.backgroundColor = .systemBackground
textView.translatesAutoresizingMaskIntoConstraints = false codeView.refreshControl = UIRefreshControl()
textView.isEditable = false codeView.refreshControl?.addTarget(self, action: #selector(reload), for: .valueChanged)
textView.isSelectable = true view.addSubview(codeView)
textView.alwaysBounceVertical = true
textView.alwaysBounceHorizontal = true
textView.showsHorizontalScrollIndicator = true
textView.textContainer.widthTracksTextView = false
textView.textContainer.lineFragmentPadding = 8
textView.refreshControl = UIRefreshControl()
textView.refreshControl?.addTarget(self, action: #selector(reload), for: .valueChanged)
view.addSubview(textView)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
textView.leadingAnchor.constraint(equalTo: view.leadingAnchor), codeView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
textView.trailingAnchor.constraint(equalTo: view.trailingAnchor), codeView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
textView.topAnchor.constraint(equalTo: view.topAnchor), codeView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
textView.bottomAnchor.constraint(equalTo: view.bottomAnchor), codeView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
]) ])
beginNavigationLoading(spinner) beginNavigationLoading(spinner)
reload() reload()
@@ -1063,12 +1052,12 @@ final class DiffViewController: UIViewController {
) )
} }
title = page.title title = page.title
textView.attributedText = diffText(page) codeView.display(diffText(page))
} catch { } catch {
if !Task.isCancelled { show(error: error) } if !Task.isCancelled { show(error: error) }
} }
endNavigationLoading(spinner) endNavigationLoading(spinner)
textView.refreshControl?.endRefreshing() codeView.refreshControl?.endRefreshing()
} }
} }