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