krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.9.0: Hutch/Views/Repositories/FileTreeView.swift · raw
1import SwiftUI
2import UIKit
3
4private typealias ViewColor = SwiftUI.Color
5
6struct FileTreeView: View {
7 let repository: RepositorySummary
8 let client: SRHTClient
9
10 @State private var viewModel: FileTreeViewModel?
11
12 var body: some View {
13 Group {
14 if let viewModel {
15 FileTreeContentView(viewModel: viewModel)
16 } else {
17 SRHTLoadingStateView(message: "Loading files…")
18 }
19 }
20 .task {
21 if viewModel == nil {
22 let vm = FileTreeViewModel(
23 repositoryRid: repository.rid,
24 service: repository.service,
25 client: client
26 )
27 viewModel = vm
28 async let loadTree: () = vm.loadRootTree()
29 async let loadRefs: () = vm.loadReferences()
30 _ = await (loadTree, loadRefs)
31 }
32 }
33 }
34}
35
36// MARK: - Content View
37
38private struct FileTreeContentView: View {
39 let viewModel: FileTreeViewModel
40
41 @AppStorage(AppStorageKeys.wrapRepositoryFileLines) private var wrapRepositoryFileLines = false
42 @State private var showRefPicker = false
43 @State private var showFileShareSheet = false
44 @State private var showShareUnavailableAlert = false
45 @State private var didCopyFileContents = false
46 @State private var copyResetTask: Task<Void, Never>?
47
48 var body: some View {
49 VStack(spacing: 0) {
50 breadcrumbBar
51 Divider()
52 contentArea
53 }
54 .toolbar {
55 ToolbarItem(placement: .topBarTrailing) {
56 Button {
57 showRefPicker = true
58 } label: {
59 Label(
60 revspecLabel,
61 systemImage: "arrow.triangle.branch"
62 )
63 .font(.subheadline)
64 }
65 }
66 }
67 .sheet(isPresented: $showRefPicker) {
68 RefPickerSheet(viewModel: viewModel, isPresented: $showRefPicker)
69 }
70 .srhtErrorBanner(error: Binding(
71 get: { viewModel.error },
72 set: { viewModel.error = $0 }
73 ))
74 .onChange(of: viewModel.viewingEntry?.name) { _, _ in
75 resetCopyConfirmation()
76 }
77 }
78
79 private var revspecLabel: String {
80 let revspec = viewModel.revspec
81 if revspec == "HEAD" {
82 return "HEAD"
83 }
84 if revspec.hasPrefix("refs/heads/") {
85 return String(revspec.dropFirst("refs/heads/".count))
86 } else if revspec.hasPrefix("refs/tags/") {
87 return String(revspec.dropFirst("refs/tags/".count))
88 }
89 return revspec
90 }
91
92 // MARK: - Breadcrumb Bar
93
94 private var breadcrumbBar: some View {
95 ScrollView(.horizontal, showsIndicators: false) {
96 HStack(spacing: 12) {
97 HStack(spacing: 4) {
98 ForEach(Array(viewModel.navStack.enumerated()), id: \.offset) { index, navEntry in
99 if index > 0 {
100 Image(systemName: "chevron.right")
101 .font(.caption2)
102 .foregroundStyle(.tertiary)
103 }
104
105 Button {
106 Task {
107 await viewModel.navigateToBreadcrumb(at: index)
108 }
109 } label: {
110 Text(navEntry.name)
111 .font(.subheadline.monospaced())
112 .foregroundStyle(
113 index == viewModel.navStack.count - 1 && viewModel.viewingEntry == nil
114 ? .primary : .secondary
115 )
116 }
117 .buttonStyle(.plain)
118 }
119
120 if let viewing = viewModel.viewingEntry {
121 Image(systemName: "chevron.right")
122 .font(.caption2)
123 .foregroundStyle(.tertiary)
124
125 Text(viewing.name)
126 .font(.subheadline.monospaced())
127 .foregroundStyle(.primary)
128 }
129 }
130
131 Spacer(minLength: 0)
132 }
133 .padding(.horizontal)
134 .padding(.vertical, 8)
135 }
136 .background(.bar)
137 }
138
139 // MARK: - Content Area
140
141 @ViewBuilder
142 private var contentArea: some View {
143 if viewModel.isLoading, viewModel.entries.isEmpty, viewModel.viewingEntry == nil {
144 SRHTLoadingStateView(message: "Loading files…")
145 } else if let entry = viewModel.viewingEntry, let object = viewModel.viewingObject {
146 // Viewing a file
147 fileContentView(entry: entry, object: object)
148 } else if let error = viewModel.error, viewModel.entries.isEmpty {
149 SRHTErrorStateView(
150 title: "Couldn't Load Files",
151 message: error,
152 retryAction: { await viewModel.loadRootTree() }
153 )
154 } else if !viewModel.entries.isEmpty {
155 // Viewing a directory listing
156 treeListView
157 } else if viewModel.navStack.isEmpty {
158 ContentUnavailableView(
159 "No Files",
160 systemImage: "folder",
161 description: Text("This repository could not be loaded.")
162 )
163 } else {
164 ContentUnavailableView(
165 "Empty Directory",
166 systemImage: "folder",
167 description: Text("This directory has no files.")
168 )
169 }
170 }
171
172 private func shareFileContents(_ text: String) {
173 if text.isEmpty {
174 showShareUnavailableAlert = true
175 } else {
176 showFileShareSheet = true
177 }
178 }
179
180 private func copyFileContents(_ text: String) {
181 UIPasteboard.general.string = text
182 didCopyFileContents = true
183 copyResetTask?.cancel()
184 copyResetTask = Task {
185 try? await Task.sleep(for: .seconds(2))
186 guard !Task.isCancelled else { return }
187 await MainActor.run {
188 didCopyFileContents = false
189 }
190 }
191 }
192
193 private func resetCopyConfirmation() {
194 copyResetTask?.cancel()
195 copyResetTask = nil
196 didCopyFileContents = false
197 }
198
199 // MARK: - File Content View
200
201 @ViewBuilder
202 private func fileContentView(entry: TreeEntry, object: GitObject) -> some View {
203 switch object {
204 case .textBlob(let blob):
205 textBlobView(entry, blob: blob)
206 case .binaryBlob(let blob):
207 binaryBlobView(entry: entry, blob: blob)
208 default:
209 ContentUnavailableView(
210 "Unknown Object",
211 systemImage: "questionmark.folder",
212 description: Text("Cannot display this object type.")
213 )
214 }
215 }
216
217 // MARK: - Tree List
218
219 private var treeListView: some View {
220 let sorted = viewModel.entries.sorted { a, b in
221 let aIsTree = a.object?.isTree == true
222 let bIsTree = b.object?.isTree == true
223 if aIsTree != bIsTree { return aIsTree }
224 return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending
225 }
226
227 return List(sorted) { entry in
228 TreeEntryRow(entry: entry)
229 .contentShape(Rectangle())
230 .onTapGesture {
231 Task {
232 await viewModel.navigateInto(entry: entry)
233 }
234 }
235 .themedRow()
236 }
237 .themedList()
238 .listStyle(.plain)
239 .refreshable {
240 await viewModel.loadRootTree()
241 }
242 }
243
244 // MARK: - Text Blob
245
246 @ViewBuilder
247 private func textBlobView(_ entry: TreeEntry, blob: GitTextBlob) -> some View {
248 let text = blob.text ?? ""
249
250 VStack(spacing: 0) {
251 CodeFileTextView(
252 text: text,
253 fileName: entry.name,
254 wrapLines: wrapRepositoryFileLines
255 )
256 }
257 .safeAreaInset(edge: .bottom, spacing: 0) {
258 fileActionToolbar(text: text)
259 }
260 .sheet(isPresented: $showFileShareSheet) {
261 FileContentShareSheet(activityItems: [text])
262 }
263 .alert("Share Unavailable", isPresented: $showShareUnavailableAlert) {
264 Button("OK", role: .cancel) {
265 // no-op: .cancel role handles alert dismissal
266 }
267 } message: {
268 Text(SRHTShareTarget.file.fallbackMessage)
269 }
270 }
271
272 // MARK: - Binary Blob
273
274 @ViewBuilder
275 private func binaryBlobView(entry: TreeEntry, blob: GitBinaryBlob) -> some View {
276 VStack(spacing: 16) {
277 Spacer()
278
279 Image(systemName: "doc.zipper")
280 .font(.system(size: 48))
281 .foregroundStyle(.secondary)
282
283 Text(entry.name)
284 .font(.headline)
285
286 if let size = blob.size {
287 Text(formatBytes(size))
288 .font(.subheadline)
289 .foregroundStyle(.secondary)
290 }
291
292 Text("Binary file — cannot be displayed inline.")
293 .font(.subheadline)
294 .foregroundStyle(.tertiary)
295
296 if let content = blob.content, let url = URL(string: content) {
297 Link(destination: url) {
298 Label("Open in Safari", systemImage: "safari")
299 }
300 .buttonStyle(.borderedProminent)
301 }
302
303 Button {
304 viewModel.dismissFileView()
305 } label: {
306 Text("Back to directory")
307 }
308
309 Spacer()
310 }
311 .frame(maxWidth: .infinity)
312 }
313
314 // MARK: - Helpers
315
316 private func formatBytes(_ bytes: Int) -> String {
317 let formatter = ByteCountFormatter()
318 formatter.countStyle = .file
319 return formatter.string(fromByteCount: Int64(bytes))
320 }
321
322 private func fileActionToolbar(text: String) -> some View {
323 HStack(spacing: 0) {
324 toolbarButton(
325 title: "Share",
326 systemImage: "square.and.arrow.up"
327 ) {
328 shareFileContents(text)
329 }
330
331 toolbarButton(
332 title: didCopyFileContents ? "Copied" : "Copy All",
333 systemImage: didCopyFileContents ? "checkmark" : "doc.on.doc"
334 ) {
335 copyFileContents(text)
336 }
337
338 toolbarButton(
339 title: wrapRepositoryFileLines ? "Wrap On" : "Wrap Off",
340 systemImage: "text.word.spacing"
341 ) {
342 wrapRepositoryFileLines.toggle()
343 }
344 }
345 .padding(.horizontal, 8)
346 .padding(.top, 10)
347 .padding(.bottom, 8)
348 .background(.bar)
349 .overlay(alignment: .top) {
350 Divider()
351 }
352 }
353
354 private func toolbarButton(
355 title: String,
356 systemImage: String,
357 action: @escaping () -> Void
358 ) -> some View {
359 Button(action: action) {
360 VStack(spacing: 4) {
361 Image(systemName: systemImage)
362 .font(.system(size: 17, weight: .semibold))
363 Text(title)
364 .font(.caption2)
365 .lineLimit(1)
366 }
367 .frame(maxWidth: .infinity)
368 .contentShape(Rectangle())
369 }
370 .buttonStyle(.plain)
371 .foregroundStyle(.primary)
372 }
373}
374
375struct CodeFileTextView: UIViewRepresentable {
376 let text: String
377 let fileName: String
378 let wrapLines: Bool
379
380 private let font = UIFont(name: "SFMono-Regular", size: 12)
381 ?? UIFont.monospacedSystemFont(ofSize: 12, weight: .regular)
382
383 func makeUIView(context _: Context) -> CodeFileUIView {
384 CodeFileUIView(
385 text: text,
386 fileName: fileName,
387 font: font,
388 wrapLines: wrapLines
389 )
390 }
391
392 func updateUIView(_ uiView: CodeFileUIView, context _: Context) {
393 uiView.updateContent(
394 text: text,
395 fileName: fileName,
396 font: font,
397 wrapLines: wrapLines
398 )
399 }
400}
401
402struct FileContentShareSheet: UIViewControllerRepresentable {
403 let activityItems: [Any]
404
405 func makeUIViewController(context _: Context) -> UIActivityViewController {
406 UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
407 }
408
409 func updateUIViewController(_ : UIActivityViewController, context _: Context) {
410 // no-op: UIActivityViewController manages its own state after presentation
411 }
412}
413
414final class CodeFileUIView: UIView {
415 private enum Layout {
416 static let verticalPadding: CGFloat = 12
417 static let gutterLeadingPadding: CGFloat = 12
418 static let gutterTrailingPadding: CGFloat = 8
419 }
420
421 private final class LineRow {
422 let gutterRow = UIView()
423 let gutterLabel = UILabel()
424 let codeRow = UIView()
425 let codeLabel = UILabel()
426 let gutterHeightConstraint: NSLayoutConstraint
427 let codeHeightConstraint: NSLayoutConstraint
428
429 init() {
430 gutterRow.translatesAutoresizingMaskIntoConstraints = false
431 gutterLabel.translatesAutoresizingMaskIntoConstraints = false
432 codeRow.translatesAutoresizingMaskIntoConstraints = false
433 codeLabel.translatesAutoresizingMaskIntoConstraints = false
434
435 gutterHeightConstraint = gutterRow.heightAnchor.constraint(equalToConstant: 0)
436 codeHeightConstraint = codeRow.heightAnchor.constraint(equalToConstant: 0)
437 }
438 }
439
440 private let outerScrollView = UIScrollView()
441 private let contentView = UIView()
442 private let gutterContainerView = UIView()
443 private let gutterStackView = UIStackView()
444 private let horizontalScrollView = UIScrollView()
445 private let codeContainerView = UIView()
446 private let codeStackView = UIStackView()
447
448 private var codeContainerWidthConstraint: NSLayoutConstraint?
449 private var codeContainerExplicitWidthConstraint: NSLayoutConstraint?
450 private var gutterWidthConstraint: NSLayoutConstraint?
451
452 private var rows: [LineRow] = []
453 private var currentText = ""
454 private var currentFileName = ""
455 private var currentFont: UIFont
456 private var wrapLines: Bool
457 private var needsLineLayoutUpdate = true
458 private var lastMeasuredCodeWidth: CGFloat = 0
459 private var syntaxHighlighter: SyntaxHighlighter?
460 private var syntaxHighlighterTheme: SyntaxHighlightTheme?
461
462 init(text: String, fileName: String, font: UIFont, wrapLines: Bool) {
463 self.currentFont = font
464 self.wrapLines = wrapLines
465 super.init(frame: .zero)
466 setupViews()
467 registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: CodeFileUIView, _: UITraitCollection) in
468 view.rebuildRows()
469 view.needsLineLayoutUpdate = true
470 view.setNeedsLayout()
471 }
472 updateContent(text: text, fileName: fileName, font: font, wrapLines: wrapLines)
473 }
474
475 @available(*, unavailable)
476 required init?(coder _: NSCoder) {
477 fatalError("init(coder:) has not been implemented")
478 }
479
480 override func layoutSubviews() {
481 super.layoutSubviews()
482 updateLineLayoutsIfNeeded()
483 }
484
485 func updateContent(text: String, fileName: String, font: UIFont, wrapLines: Bool) {
486 let contentChanged = text != currentText || fileName != currentFileName || font != currentFont
487 let wrapChanged = wrapLines != self.wrapLines
488
489 currentText = text
490 currentFileName = fileName
491 currentFont = font
492 self.wrapLines = wrapLines
493
494 if contentChanged {
495 rebuildRows()
496 }
497
498 if contentChanged || wrapChanged {
499 updateWrapConfiguration(resetHorizontalOffset: wrapChanged)
500 needsLineLayoutUpdate = true
501 setNeedsLayout()
502 layoutIfNeeded()
503 }
504 }
505
506 private func setupViews() {
507 backgroundColor = .systemBackground
508
509 outerScrollView.translatesAutoresizingMaskIntoConstraints = false
510 contentView.translatesAutoresizingMaskIntoConstraints = false
511 gutterContainerView.translatesAutoresizingMaskIntoConstraints = false
512 gutterStackView.translatesAutoresizingMaskIntoConstraints = false
513 horizontalScrollView.translatesAutoresizingMaskIntoConstraints = false
514 codeContainerView.translatesAutoresizingMaskIntoConstraints = false
515 codeStackView.translatesAutoresizingMaskIntoConstraints = false
516
517 gutterStackView.axis = .vertical
518 gutterStackView.alignment = .fill
519 gutterStackView.distribution = .fill
520 gutterStackView.spacing = 0
521
522 codeStackView.axis = .vertical
523 codeStackView.alignment = .fill
524 codeStackView.distribution = .fill
525 codeStackView.spacing = 0
526
527 outerScrollView.alwaysBounceVertical = true
528 horizontalScrollView.alwaysBounceVertical = false
529 horizontalScrollView.showsVerticalScrollIndicator = false
530
531 addSubview(outerScrollView)
532 outerScrollView.addSubview(contentView)
533 contentView.addSubview(gutterContainerView)
534 contentView.addSubview(horizontalScrollView)
535 gutterContainerView.addSubview(gutterStackView)
536 horizontalScrollView.addSubview(codeContainerView)
537 codeContainerView.addSubview(codeStackView)
538
539 codeContainerWidthConstraint = codeContainerView.widthAnchor.constraint(equalTo: horizontalScrollView.frameLayoutGuide.widthAnchor)
540 codeContainerExplicitWidthConstraint = codeContainerView.widthAnchor.constraint(equalToConstant: 0)
541 gutterWidthConstraint = gutterContainerView.widthAnchor.constraint(equalToConstant: 0)
542
543 NSLayoutConstraint.activate([
544 outerScrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
545 outerScrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
546 outerScrollView.topAnchor.constraint(equalTo: topAnchor),
547 outerScrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
548
549 contentView.leadingAnchor.constraint(equalTo: outerScrollView.contentLayoutGuide.leadingAnchor),
550 contentView.trailingAnchor.constraint(equalTo: outerScrollView.contentLayoutGuide.trailingAnchor),
551 contentView.topAnchor.constraint(equalTo: outerScrollView.contentLayoutGuide.topAnchor),
552 contentView.bottomAnchor.constraint(equalTo: outerScrollView.contentLayoutGuide.bottomAnchor),
553 contentView.widthAnchor.constraint(equalTo: outerScrollView.frameLayoutGuide.widthAnchor),
554
555 gutterContainerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
556 gutterContainerView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: Layout.verticalPadding),
557 gutterContainerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -Layout.verticalPadding),
558
559 horizontalScrollView.leadingAnchor.constraint(equalTo: gutterContainerView.trailingAnchor),
560 horizontalScrollView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
561 horizontalScrollView.topAnchor.constraint(equalTo: gutterContainerView.topAnchor),
562 horizontalScrollView.bottomAnchor.constraint(equalTo: gutterContainerView.bottomAnchor),
563
564 gutterStackView.leadingAnchor.constraint(equalTo: gutterContainerView.leadingAnchor, constant: Layout.gutterLeadingPadding),
565 gutterStackView.trailingAnchor.constraint(equalTo: gutterContainerView.trailingAnchor, constant: -Layout.gutterTrailingPadding),
566 gutterStackView.topAnchor.constraint(equalTo: gutterContainerView.topAnchor),
567 gutterStackView.bottomAnchor.constraint(equalTo: gutterContainerView.bottomAnchor),
568
569 codeContainerView.leadingAnchor.constraint(equalTo: horizontalScrollView.contentLayoutGuide.leadingAnchor),
570 codeContainerView.trailingAnchor.constraint(equalTo: horizontalScrollView.contentLayoutGuide.trailingAnchor),
571 codeContainerView.topAnchor.constraint(equalTo: horizontalScrollView.contentLayoutGuide.topAnchor),
572 codeContainerView.bottomAnchor.constraint(equalTo: horizontalScrollView.contentLayoutGuide.bottomAnchor),
573 codeContainerView.heightAnchor.constraint(equalTo: horizontalScrollView.frameLayoutGuide.heightAnchor),
574
575 codeStackView.leadingAnchor.constraint(equalTo: codeContainerView.leadingAnchor),
576 codeStackView.trailingAnchor.constraint(equalTo: codeContainerView.trailingAnchor),
577 codeStackView.topAnchor.constraint(equalTo: codeContainerView.topAnchor),
578 codeStackView.bottomAnchor.constraint(equalTo: codeContainerView.bottomAnchor)
579 ])
580
581 gutterWidthConstraint?.isActive = true
582 }
583
584 private func rebuildRows() {
585 rows.forEach { row in
586 gutterStackView.removeArrangedSubview(row.gutterRow)
587 row.gutterRow.removeFromSuperview()
588 codeStackView.removeArrangedSubview(row.codeRow)
589 row.codeRow.removeFromSuperview()
590 }
591 rows.removeAll()
592
593 let attributedText = highlightedAttributedText()
594 let lines = makeLines(from: attributedText, font: currentFont)
595
596 gutterWidthConstraint?.constant = Self.gutterWidth(lineCount: lines.count, font: currentFont)
597
598 for line in lines {
599 let row = makeRow(for: line)
600 rows.append(row)
601 gutterStackView.addArrangedSubview(row.gutterRow)
602 codeStackView.addArrangedSubview(row.codeRow)
603 }
604 }
605
606 /// Highlighting cap: above this size, skip tokenizing to keep the main
607 /// thread responsive and render plain, label-colored text instead.
608 private static let maxHighlightableLength = 100_000
609
610 private func highlightedAttributedText() -> NSAttributedString {
611 let plain = NSAttributedString(
612 string: currentText,
613 attributes: [.font: currentFont, .foregroundColor: UIColor.label]
614 )
615
616 guard currentText.count <= Self.maxHighlightableLength,
617 let language = SyntaxHighlighter.language(forFileName: currentFileName) else {
618 return plain
619 }
620
621 let theme = SyntaxHighlightTheme(userInterfaceStyle: traitCollection.userInterfaceStyle)
622 if syntaxHighlighterTheme != theme || syntaxHighlighter == nil {
623 syntaxHighlighter = SyntaxHighlighter(theme: theme)
624 syntaxHighlighterTheme = theme
625 }
626
627 return syntaxHighlighter?.attributedText(for: currentText, language: language, font: currentFont) ?? plain
628 }
629
630 private func makeRow(for line: CodeFileLineData) -> LineRow {
631 let row = LineRow()
632
633 row.gutterLabel.font = currentFont
634 row.gutterLabel.textColor = .secondaryLabel
635 row.gutterLabel.textAlignment = .right
636 row.gutterLabel.text = line.number
637
638 row.codeLabel.attributedText = line.text
639 row.codeLabel.font = currentFont
640
641 row.gutterRow.addSubview(row.gutterLabel)
642 row.codeRow.addSubview(row.codeLabel)
643
644 NSLayoutConstraint.activate([
645 row.gutterHeightConstraint,
646 row.codeHeightConstraint,
647
648 row.gutterLabel.leadingAnchor.constraint(equalTo: row.gutterRow.leadingAnchor),
649 row.gutterLabel.trailingAnchor.constraint(equalTo: row.gutterRow.trailingAnchor),
650 row.gutterLabel.topAnchor.constraint(equalTo: row.gutterRow.topAnchor),
651 row.gutterLabel.bottomAnchor.constraint(lessThanOrEqualTo: row.gutterRow.bottomAnchor),
652
653 row.codeLabel.leadingAnchor.constraint(equalTo: row.codeRow.leadingAnchor),
654 row.codeLabel.trailingAnchor.constraint(equalTo: row.codeRow.trailingAnchor),
655 row.codeLabel.topAnchor.constraint(equalTo: row.codeRow.topAnchor),
656 row.codeLabel.bottomAnchor.constraint(lessThanOrEqualTo: row.codeRow.bottomAnchor)
657 ])
658
659 return row
660 }
661
662 private func updateWrapConfiguration(resetHorizontalOffset: Bool) {
663 horizontalScrollView.alwaysBounceHorizontal = !wrapLines
664 horizontalScrollView.isScrollEnabled = !wrapLines
665
666 if wrapLines {
667 codeContainerWidthConstraint?.isActive = true
668 codeContainerExplicitWidthConstraint?.isActive = false
669 } else {
670 codeContainerWidthConstraint?.isActive = false
671 codeContainerExplicitWidthConstraint?.isActive = true
672 }
673
674 for row in rows {
675 row.codeLabel.numberOfLines = wrapLines ? 0 : 1
676 row.codeLabel.lineBreakMode = wrapLines ? .byWordWrapping : .byClipping
677 }
678
679 if resetHorizontalOffset {
680 horizontalScrollView.setContentOffset(.zero, animated: false)
681 }
682 }
683
684 private func updateLineLayoutsIfNeeded() {
685 let availableWidth = max(horizontalScrollView.bounds.width, 0)
686 let shouldUpdateForWidth = wrapLines && abs(availableWidth - lastMeasuredCodeWidth) > 0.5
687
688 guard needsLineLayoutUpdate || shouldUpdateForWidth else { return }
689
690 lastMeasuredCodeWidth = availableWidth
691 let measurementWidth = wrapLines ? max(availableWidth, 1) : CGFloat.greatestFiniteMagnitude
692
693 for row in rows {
694 row.codeLabel.preferredMaxLayoutWidth = wrapLines ? measurementWidth : 0
695 let measuredSize = row.codeLabel.sizeThatFits(
696 CGSize(width: measurementWidth, height: CGFloat.greatestFiniteMagnitude)
697 )
698 let rowHeight = max(ceil(measuredSize.height), ceil(currentFont.lineHeight))
699 row.gutterHeightConstraint.constant = rowHeight
700 row.codeHeightConstraint.constant = rowHeight
701 }
702
703 if wrapLines {
704 codeContainerExplicitWidthConstraint?.constant = 0
705 } else {
706 let maxLineWidth = rows.reduce(CGFloat(0)) { partialResult, row in
707 let measuredWidth = row.codeLabel.sizeThatFits(
708 CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
709 ).width
710 return max(partialResult, ceil(measuredWidth))
711 }
712 codeContainerExplicitWidthConstraint?.constant = max(maxLineWidth, availableWidth)
713 }
714
715 needsLineLayoutUpdate = false
716 }
717
718 private func makeLines(from attributedText: NSAttributedString, font: UIFont) -> [CodeFileLineData] {
719 let string = attributedText.string as NSString
720 var lines: [CodeFileLineData] = []
721 var currentLocation = 0
722 var lineNumber = 1
723
724 while currentLocation < attributedText.length {
725 let searchRange = NSRange(location: currentLocation, length: attributedText.length - currentLocation)
726 let newlineRange = string.range(of: "\n", options: [], range: searchRange)
727 let lineRange: NSRange
728
729 if newlineRange.location == NSNotFound {
730 lineRange = searchRange
731 currentLocation = attributedText.length
732 } else {
733 lineRange = NSRange(location: currentLocation, length: newlineRange.location - currentLocation)
734 currentLocation = newlineRange.location + newlineRange.length
735 }
736
737 lines.append(CodeFileLineData(
738 number: String(lineNumber),
739 text: attributedText.attributedSubstring(from: lineRange)
740 ))
741 lineNumber += 1
742 }
743
744 if attributedText.length == 0 || string.hasSuffix("\n") {
745 lines.append(CodeFileLineData(
746 number: String(lineNumber),
747 text: NSAttributedString(string: "", attributes: [.font: font])
748 ))
749 }
750
751 return lines
752 }
753
754 private static func gutterWidth(lineCount: Int, font: UIFont) -> CGFloat {
755 let digits = String(max(lineCount, 1)).count
756 let sample = String(repeating: "8", count: digits)
757 let numberWidth = ceil((sample as NSString).size(withAttributes: [.font: font]).width)
758 return Layout.gutterLeadingPadding + numberWidth + Layout.gutterTrailingPadding
759 }
760}
761
762private struct CodeFileLineData {
763 let number: String
764 let text: NSAttributedString
765}
766
767// MARK: - Tree Entry Row
768
769private struct TreeEntryRow: View {
770 let entry: TreeEntry
771
772 var body: some View {
773 Label {
774 Text(entry.name)
775 .font(.body.monospaced())
776 .lineLimit(1)
777 } icon: {
778 Image(systemName: iconName)
779 .foregroundStyle(iconColor)
780 }
781 }
782
783 private var iconName: String {
784 switch entry.object {
785 case .tree: "folder.fill"
786 case .unknown: "questionmark.circle"
787 default: "doc"
788 }
789 }
790
791 private var iconColor: ViewColor {
792 switch entry.object {
793 case .tree: .blue
794 case .unknown: .orange
795 default: .secondary
796 }
797 }
798}
799
800// MARK: - Ref Picker Sheet
801
802private struct RefPickerSheet: View {
803 let viewModel: FileTreeViewModel
804 @Binding var isPresented: Bool
805
806 var body: some View {
807 NavigationStack {
808 List {
809 Section {
810 Button {
811 Task {
812 await viewModel.changeRevspec("HEAD")
813 isPresented = false
814 }
815 } label: {
816 refRow(
817 title: "HEAD",
818 systemImage: "arrow.triangle.branch",
819 color: .blue,
820 isSelected: viewModel.revspec == "HEAD"
821 )
822 }
823 .buttonStyle(.plain)
824 .themedRow()
825 }
826
827 if !viewModel.branches.isEmpty {
828 Section("Branches") {
829 ForEach(viewModel.branches, id: \.name) { ref in
830 Button {
831 Task {
832 await viewModel.changeRevspec(ref.name)
833 isPresented = false
834 }
835 } label: {
836 refRow(
837 title: ref.name.replacingOccurrences(of: "refs/heads/", with: ""),
838 systemImage: "arrow.triangle.branch",
839 color: .blue,
840 isSelected: viewModel.revspec == ref.name
841 )
842 }
843 .buttonStyle(.plain)
844 }
845 .themedRow()
846 }
847 }
848
849 if !viewModel.tags.isEmpty {
850 Section("Tags") {
851 ForEach(viewModel.tags, id: \.name) { ref in
852 Button {
853 Task {
854 await viewModel.changeRevspec(ref.name)
855 isPresented = false
856 }
857 } label: {
858 refRow(
859 title: ref.name.replacingOccurrences(of: "refs/tags/", with: ""),
860 systemImage: "tag",
861 color: .orange,
862 isSelected: viewModel.revspec == ref.name
863 )
864 }
865 .buttonStyle(.plain)
866 }
867 .themedRow()
868 }
869 }
870 }
871 .themedList()
872 .listStyle(.insetGrouped)
873 .navigationTitle("Select Ref")
874 .navigationBarTitleDisplayMode(.inline)
875 .toolbar {
876 ToolbarItem(placement: .cancellationAction) {
877 Button("Cancel") {
878 isPresented = false
879 }
880 }
881 }
882 .overlay {
883 if viewModel.isLoadingRefs {
884 SRHTLoadingStateView(message: "Loading references…")
885 }
886 }
887 }
888 }
889
890 private func refRow(title: String, systemImage: String, color: ViewColor, isSelected: Bool) -> some View {
891 HStack(spacing: 12) {
892 Image(systemName: systemImage)
893 .foregroundStyle(color)
894
895 Text(title)
896 .font(.body.monospaced())
897 .foregroundStyle(.primary)
898
899 Spacer()
900
901 if isSelected {
902 Image(systemName: "checkmark")
903 .font(.caption.weight(.semibold))
904 .foregroundStyle(.tint)
905 }
906 }
907 .contentShape(Rectangle())
908 }
909}