krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
remove-splash-highlighter: 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
460 init(text: String, fileName: String, font: UIFont, wrapLines: Bool) {
461 self.currentFont = font
462 self.wrapLines = wrapLines
463 super.init(frame: .zero)
464 setupViews()
465 updateContent(text: text, fileName: fileName, font: font, wrapLines: wrapLines)
466 }
467
468 @available(*, unavailable)
469 required init?(coder _: NSCoder) {
470 fatalError("init(coder:) has not been implemented")
471 }
472
473 override func layoutSubviews() {
474 super.layoutSubviews()
475 updateLineLayoutsIfNeeded()
476 }
477
478 func updateContent(text: String, fileName: String, font: UIFont, wrapLines: Bool) {
479 let contentChanged = text != currentText || fileName != currentFileName || font != currentFont
480 let wrapChanged = wrapLines != self.wrapLines
481
482 currentText = text
483 currentFileName = fileName
484 currentFont = font
485 self.wrapLines = wrapLines
486
487 if contentChanged {
488 rebuildRows()
489 }
490
491 if contentChanged || wrapChanged {
492 updateWrapConfiguration(resetHorizontalOffset: wrapChanged)
493 needsLineLayoutUpdate = true
494 setNeedsLayout()
495 layoutIfNeeded()
496 }
497 }
498
499 private func setupViews() {
500 backgroundColor = .systemBackground
501
502 outerScrollView.translatesAutoresizingMaskIntoConstraints = false
503 contentView.translatesAutoresizingMaskIntoConstraints = false
504 gutterContainerView.translatesAutoresizingMaskIntoConstraints = false
505 gutterStackView.translatesAutoresizingMaskIntoConstraints = false
506 horizontalScrollView.translatesAutoresizingMaskIntoConstraints = false
507 codeContainerView.translatesAutoresizingMaskIntoConstraints = false
508 codeStackView.translatesAutoresizingMaskIntoConstraints = false
509
510 gutterStackView.axis = .vertical
511 gutterStackView.alignment = .fill
512 gutterStackView.distribution = .fill
513 gutterStackView.spacing = 0
514
515 codeStackView.axis = .vertical
516 codeStackView.alignment = .fill
517 codeStackView.distribution = .fill
518 codeStackView.spacing = 0
519
520 outerScrollView.alwaysBounceVertical = true
521 horizontalScrollView.alwaysBounceVertical = false
522 horizontalScrollView.showsVerticalScrollIndicator = false
523
524 addSubview(outerScrollView)
525 outerScrollView.addSubview(contentView)
526 contentView.addSubview(gutterContainerView)
527 contentView.addSubview(horizontalScrollView)
528 gutterContainerView.addSubview(gutterStackView)
529 horizontalScrollView.addSubview(codeContainerView)
530 codeContainerView.addSubview(codeStackView)
531
532 codeContainerWidthConstraint = codeContainerView.widthAnchor.constraint(equalTo: horizontalScrollView.frameLayoutGuide.widthAnchor)
533 codeContainerExplicitWidthConstraint = codeContainerView.widthAnchor.constraint(equalToConstant: 0)
534 gutterWidthConstraint = gutterContainerView.widthAnchor.constraint(equalToConstant: 0)
535
536 NSLayoutConstraint.activate([
537 outerScrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
538 outerScrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
539 outerScrollView.topAnchor.constraint(equalTo: topAnchor),
540 outerScrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
541
542 contentView.leadingAnchor.constraint(equalTo: outerScrollView.contentLayoutGuide.leadingAnchor),
543 contentView.trailingAnchor.constraint(equalTo: outerScrollView.contentLayoutGuide.trailingAnchor),
544 contentView.topAnchor.constraint(equalTo: outerScrollView.contentLayoutGuide.topAnchor),
545 contentView.bottomAnchor.constraint(equalTo: outerScrollView.contentLayoutGuide.bottomAnchor),
546 contentView.widthAnchor.constraint(equalTo: outerScrollView.frameLayoutGuide.widthAnchor),
547
548 gutterContainerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
549 gutterContainerView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: Layout.verticalPadding),
550 gutterContainerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -Layout.verticalPadding),
551
552 horizontalScrollView.leadingAnchor.constraint(equalTo: gutterContainerView.trailingAnchor),
553 horizontalScrollView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
554 horizontalScrollView.topAnchor.constraint(equalTo: gutterContainerView.topAnchor),
555 horizontalScrollView.bottomAnchor.constraint(equalTo: gutterContainerView.bottomAnchor),
556
557 gutterStackView.leadingAnchor.constraint(equalTo: gutterContainerView.leadingAnchor, constant: Layout.gutterLeadingPadding),
558 gutterStackView.trailingAnchor.constraint(equalTo: gutterContainerView.trailingAnchor, constant: -Layout.gutterTrailingPadding),
559 gutterStackView.topAnchor.constraint(equalTo: gutterContainerView.topAnchor),
560 gutterStackView.bottomAnchor.constraint(equalTo: gutterContainerView.bottomAnchor),
561
562 codeContainerView.leadingAnchor.constraint(equalTo: horizontalScrollView.contentLayoutGuide.leadingAnchor),
563 codeContainerView.trailingAnchor.constraint(equalTo: horizontalScrollView.contentLayoutGuide.trailingAnchor),
564 codeContainerView.topAnchor.constraint(equalTo: horizontalScrollView.contentLayoutGuide.topAnchor),
565 codeContainerView.bottomAnchor.constraint(equalTo: horizontalScrollView.contentLayoutGuide.bottomAnchor),
566 codeContainerView.heightAnchor.constraint(equalTo: horizontalScrollView.frameLayoutGuide.heightAnchor),
567
568 codeStackView.leadingAnchor.constraint(equalTo: codeContainerView.leadingAnchor),
569 codeStackView.trailingAnchor.constraint(equalTo: codeContainerView.trailingAnchor),
570 codeStackView.topAnchor.constraint(equalTo: codeContainerView.topAnchor),
571 codeStackView.bottomAnchor.constraint(equalTo: codeContainerView.bottomAnchor)
572 ])
573
574 gutterWidthConstraint?.isActive = true
575 }
576
577 private func rebuildRows() {
578 rows.forEach { row in
579 gutterStackView.removeArrangedSubview(row.gutterRow)
580 row.gutterRow.removeFromSuperview()
581 codeStackView.removeArrangedSubview(row.codeRow)
582 row.codeRow.removeFromSuperview()
583 }
584 rows.removeAll()
585
586 let attributedText = CodeSyntaxHighlighter.attributedText(
587 for: currentText,
588 fileName: currentFileName,
589 font: currentFont
590 )
591 let lines = makeLines(from: attributedText, font: currentFont)
592
593 gutterWidthConstraint?.constant = Self.gutterWidth(lineCount: lines.count, font: currentFont)
594
595 for line in lines {
596 let row = makeRow(for: line)
597 rows.append(row)
598 gutterStackView.addArrangedSubview(row.gutterRow)
599 codeStackView.addArrangedSubview(row.codeRow)
600 }
601 }
602
603 private func makeRow(for line: CodeFileLineData) -> LineRow {
604 let row = LineRow()
605
606 row.gutterLabel.font = currentFont
607 row.gutterLabel.textColor = .secondaryLabel
608 row.gutterLabel.textAlignment = .right
609 row.gutterLabel.text = line.number
610
611 row.codeLabel.attributedText = line.text
612 row.codeLabel.font = currentFont
613
614 row.gutterRow.addSubview(row.gutterLabel)
615 row.codeRow.addSubview(row.codeLabel)
616
617 NSLayoutConstraint.activate([
618 row.gutterHeightConstraint,
619 row.codeHeightConstraint,
620
621 row.gutterLabel.leadingAnchor.constraint(equalTo: row.gutterRow.leadingAnchor),
622 row.gutterLabel.trailingAnchor.constraint(equalTo: row.gutterRow.trailingAnchor),
623 row.gutterLabel.topAnchor.constraint(equalTo: row.gutterRow.topAnchor),
624 row.gutterLabel.bottomAnchor.constraint(lessThanOrEqualTo: row.gutterRow.bottomAnchor),
625
626 row.codeLabel.leadingAnchor.constraint(equalTo: row.codeRow.leadingAnchor),
627 row.codeLabel.trailingAnchor.constraint(equalTo: row.codeRow.trailingAnchor),
628 row.codeLabel.topAnchor.constraint(equalTo: row.codeRow.topAnchor),
629 row.codeLabel.bottomAnchor.constraint(lessThanOrEqualTo: row.codeRow.bottomAnchor)
630 ])
631
632 return row
633 }
634
635 private func updateWrapConfiguration(resetHorizontalOffset: Bool) {
636 horizontalScrollView.alwaysBounceHorizontal = !wrapLines
637 horizontalScrollView.isScrollEnabled = !wrapLines
638
639 if wrapLines {
640 codeContainerWidthConstraint?.isActive = true
641 codeContainerExplicitWidthConstraint?.isActive = false
642 } else {
643 codeContainerWidthConstraint?.isActive = false
644 codeContainerExplicitWidthConstraint?.isActive = true
645 }
646
647 for row in rows {
648 row.codeLabel.numberOfLines = wrapLines ? 0 : 1
649 row.codeLabel.lineBreakMode = wrapLines ? .byWordWrapping : .byClipping
650 }
651
652 if resetHorizontalOffset {
653 horizontalScrollView.setContentOffset(.zero, animated: false)
654 }
655 }
656
657 private func updateLineLayoutsIfNeeded() {
658 let availableWidth = max(horizontalScrollView.bounds.width, 0)
659 let shouldUpdateForWidth = wrapLines && abs(availableWidth - lastMeasuredCodeWidth) > 0.5
660
661 guard needsLineLayoutUpdate || shouldUpdateForWidth else { return }
662
663 lastMeasuredCodeWidth = availableWidth
664 let measurementWidth = wrapLines ? max(availableWidth, 1) : CGFloat.greatestFiniteMagnitude
665
666 for row in rows {
667 row.codeLabel.preferredMaxLayoutWidth = wrapLines ? measurementWidth : 0
668 let measuredSize = row.codeLabel.sizeThatFits(
669 CGSize(width: measurementWidth, height: CGFloat.greatestFiniteMagnitude)
670 )
671 let rowHeight = max(ceil(measuredSize.height), ceil(currentFont.lineHeight))
672 row.gutterHeightConstraint.constant = rowHeight
673 row.codeHeightConstraint.constant = rowHeight
674 }
675
676 if wrapLines {
677 codeContainerExplicitWidthConstraint?.constant = 0
678 } else {
679 let maxLineWidth = rows.reduce(CGFloat(0)) { partialResult, row in
680 let measuredWidth = row.codeLabel.sizeThatFits(
681 CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
682 ).width
683 return max(partialResult, ceil(measuredWidth))
684 }
685 codeContainerExplicitWidthConstraint?.constant = max(maxLineWidth, availableWidth)
686 }
687
688 needsLineLayoutUpdate = false
689 }
690
691 private func makeLines(from attributedText: NSAttributedString, font: UIFont) -> [CodeFileLineData] {
692 let string = attributedText.string as NSString
693 var lines: [CodeFileLineData] = []
694 var currentLocation = 0
695 var lineNumber = 1
696
697 while currentLocation < attributedText.length {
698 let searchRange = NSRange(location: currentLocation, length: attributedText.length - currentLocation)
699 let newlineRange = string.range(of: "\n", options: [], range: searchRange)
700 let lineRange: NSRange
701
702 if newlineRange.location == NSNotFound {
703 lineRange = searchRange
704 currentLocation = attributedText.length
705 } else {
706 lineRange = NSRange(location: currentLocation, length: newlineRange.location - currentLocation)
707 currentLocation = newlineRange.location + newlineRange.length
708 }
709
710 lines.append(CodeFileLineData(
711 number: String(lineNumber),
712 text: attributedText.attributedSubstring(from: lineRange)
713 ))
714 lineNumber += 1
715 }
716
717 if attributedText.length == 0 || string.hasSuffix("\n") {
718 lines.append(CodeFileLineData(
719 number: String(lineNumber),
720 text: NSAttributedString(string: "", attributes: [.font: font])
721 ))
722 }
723
724 return lines
725 }
726
727 private static func gutterWidth(lineCount: Int, font: UIFont) -> CGFloat {
728 let digits = String(max(lineCount, 1)).count
729 let sample = String(repeating: "8", count: digits)
730 let numberWidth = ceil((sample as NSString).size(withAttributes: [.font: font]).width)
731 return Layout.gutterLeadingPadding + numberWidth + Layout.gutterTrailingPadding
732 }
733}
734
735private struct CodeFileLineData {
736 let number: String
737 let text: NSAttributedString
738}
739
740private enum CodeSyntaxHighlighter {
741 static func attributedText(for text: String, fileName _: String, font: UIFont) -> NSAttributedString {
742 NSAttributedString(
743 string: text,
744 attributes: [
745 .font: font,
746 .foregroundColor: UIColor.label
747 ]
748 )
749 }
750}
751
752// MARK: - Tree Entry Row
753
754private struct TreeEntryRow: View {
755 let entry: TreeEntry
756
757 var body: some View {
758 Label {
759 Text(entry.name)
760 .font(.body.monospaced())
761 .lineLimit(1)
762 } icon: {
763 Image(systemName: iconName)
764 .foregroundStyle(iconColor)
765 }
766 }
767
768 private var iconName: String {
769 switch entry.object {
770 case .tree: "folder.fill"
771 case .unknown: "questionmark.circle"
772 default: "doc"
773 }
774 }
775
776 private var iconColor: ViewColor {
777 switch entry.object {
778 case .tree: .blue
779 case .unknown: .orange
780 default: .secondary
781 }
782 }
783}
784
785// MARK: - Ref Picker Sheet
786
787private struct RefPickerSheet: View {
788 let viewModel: FileTreeViewModel
789 @Binding var isPresented: Bool
790
791 var body: some View {
792 NavigationStack {
793 List {
794 Section {
795 Button {
796 Task {
797 await viewModel.changeRevspec("HEAD")
798 isPresented = false
799 }
800 } label: {
801 refRow(
802 title: "HEAD",
803 systemImage: "arrow.triangle.branch",
804 color: .blue,
805 isSelected: viewModel.revspec == "HEAD"
806 )
807 }
808 .buttonStyle(.plain)
809 .themedRow()
810 }
811
812 if !viewModel.branches.isEmpty {
813 Section("Branches") {
814 ForEach(viewModel.branches, id: \.name) { ref in
815 Button {
816 Task {
817 await viewModel.changeRevspec(ref.name)
818 isPresented = false
819 }
820 } label: {
821 refRow(
822 title: ref.name.replacingOccurrences(of: "refs/heads/", with: ""),
823 systemImage: "arrow.triangle.branch",
824 color: .blue,
825 isSelected: viewModel.revspec == ref.name
826 )
827 }
828 .buttonStyle(.plain)
829 }
830 .themedRow()
831 }
832 }
833
834 if !viewModel.tags.isEmpty {
835 Section("Tags") {
836 ForEach(viewModel.tags, id: \.name) { ref in
837 Button {
838 Task {
839 await viewModel.changeRevspec(ref.name)
840 isPresented = false
841 }
842 } label: {
843 refRow(
844 title: ref.name.replacingOccurrences(of: "refs/tags/", with: ""),
845 systemImage: "tag",
846 color: .orange,
847 isSelected: viewModel.revspec == ref.name
848 )
849 }
850 .buttonStyle(.plain)
851 }
852 .themedRow()
853 }
854 }
855 }
856 .themedList()
857 .listStyle(.insetGrouped)
858 .navigationTitle("Select Ref")
859 .navigationBarTitleDisplayMode(.inline)
860 .toolbar {
861 ToolbarItem(placement: .cancellationAction) {
862 Button("Cancel") {
863 isPresented = false
864 }
865 }
866 }
867 .overlay {
868 if viewModel.isLoadingRefs {
869 SRHTLoadingStateView(message: "Loading references…")
870 }
871 }
872 }
873 }
874
875 private func refRow(title: String, systemImage: String, color: ViewColor, isSelected: Bool) -> some View {
876 HStack(spacing: 12) {
877 Image(systemName: systemImage)
878 .foregroundStyle(color)
879
880 Text(title)
881 .font(.body.monospaced())
882 .foregroundStyle(.primary)
883
884 Spacer()
885
886 if isSelected {
887 Image(systemName: "checkmark")
888 .font(.caption.weight(.semibold))
889 .foregroundStyle(.tint)
890 }
891 }
892 .contentShape(Rectangle())
893 }
894}