krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.9.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 }
237 .listStyle(.plain)
238 .refreshable {
239 await viewModel.loadRootTree()
240 }
241 }
242
243 // MARK: - Text Blob
244
245 @ViewBuilder
246 private func textBlobView(_ entry: TreeEntry, blob: GitTextBlob) -> some View {
247 let text = blob.text ?? ""
248
249 VStack(spacing: 0) {
250 CodeFileTextView(
251 text: text,
252 fileName: entry.name,
253 wrapLines: wrapRepositoryFileLines
254 )
255 }
256 .safeAreaInset(edge: .bottom, spacing: 0) {
257 fileActionToolbar(text: text)
258 }
259 .sheet(isPresented: $showFileShareSheet) {
260 FileContentShareSheet(activityItems: [text])
261 }
262 .alert("Share Unavailable", isPresented: $showShareUnavailableAlert) {
263 Button("OK", role: .cancel) {}
264 } message: {
265 Text(SRHTShareTarget.file.fallbackMessage)
266 }
267 }
268
269 // MARK: - Binary Blob
270
271 @ViewBuilder
272 private func binaryBlobView(entry: TreeEntry, blob: GitBinaryBlob) -> some View {
273 VStack(spacing: 16) {
274 Spacer()
275
276 Image(systemName: "doc.zipper")
277 .font(.system(size: 48))
278 .foregroundStyle(.secondary)
279
280 Text(entry.name)
281 .font(.headline)
282
283 if let size = blob.size {
284 Text(formatBytes(size))
285 .font(.subheadline)
286 .foregroundStyle(.secondary)
287 }
288
289 Text("Binary file — cannot be displayed inline.")
290 .font(.subheadline)
291 .foregroundStyle(.tertiary)
292
293 if let content = blob.content, let url = URL(string: content) {
294 Link(destination: url) {
295 Label("Open in Safari", systemImage: "safari")
296 }
297 .buttonStyle(.borderedProminent)
298 }
299
300 Button {
301 viewModel.dismissFileView()
302 } label: {
303 Text("Back to directory")
304 }
305
306 Spacer()
307 }
308 .frame(maxWidth: .infinity)
309 }
310
311 // MARK: - Helpers
312
313 private func formatBytes(_ bytes: Int) -> String {
314 let formatter = ByteCountFormatter()
315 formatter.countStyle = .file
316 return formatter.string(fromByteCount: Int64(bytes))
317 }
318
319 private func fileActionToolbar(text: String) -> some View {
320 HStack(spacing: 0) {
321 toolbarButton(
322 title: "Share",
323 systemImage: "square.and.arrow.up"
324 ) {
325 shareFileContents(text)
326 }
327
328 toolbarButton(
329 title: didCopyFileContents ? "Copied" : "Copy All",
330 systemImage: didCopyFileContents ? "checkmark" : "doc.on.doc"
331 ) {
332 copyFileContents(text)
333 }
334
335 toolbarButton(
336 title: wrapRepositoryFileLines ? "Wrap On" : "Wrap Off",
337 systemImage: "text.word.spacing"
338 ) {
339 wrapRepositoryFileLines.toggle()
340 }
341 }
342 .padding(.horizontal, 8)
343 .padding(.top, 10)
344 .padding(.bottom, 8)
345 .background(.bar)
346 .overlay(alignment: .top) {
347 Divider()
348 }
349 }
350
351 private func toolbarButton(
352 title: String,
353 systemImage: String,
354 action: @escaping () -> Void
355 ) -> some View {
356 Button(action: action) {
357 VStack(spacing: 4) {
358 Image(systemName: systemImage)
359 .font(.system(size: 17, weight: .semibold))
360 Text(title)
361 .font(.caption2)
362 .lineLimit(1)
363 }
364 .frame(maxWidth: .infinity)
365 .contentShape(Rectangle())
366 }
367 .buttonStyle(.plain)
368 .foregroundStyle(.primary)
369 }
370}
371
372private struct CodeFileTextView: UIViewRepresentable {
373 let text: String
374 let fileName: String
375 let wrapLines: Bool
376
377 private let font = UIFont(name: "SFMono-Regular", size: 12)
378 ?? UIFont.monospacedSystemFont(ofSize: 12, weight: .regular)
379
380 func makeUIView(context _: Context) -> CodeFileUIView {
381 CodeFileUIView(
382 text: text,
383 fileName: fileName,
384 font: font,
385 wrapLines: wrapLines
386 )
387 }
388
389 func updateUIView(_ uiView: CodeFileUIView, context _: Context) {
390 uiView.updateContent(
391 text: text,
392 fileName: fileName,
393 font: font,
394 wrapLines: wrapLines
395 )
396 }
397}
398
399private struct FileContentShareSheet: UIViewControllerRepresentable {
400 let activityItems: [Any]
401
402 func makeUIViewController(context _: Context) -> UIActivityViewController {
403 UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
404 }
405
406 func updateUIViewController(_ : UIActivityViewController, context _: Context) {}
407}
408
409private final class CodeFileUIView: UIView {
410 private enum Layout {
411 static let verticalPadding: CGFloat = 12
412 static let gutterLeadingPadding: CGFloat = 12
413 static let gutterTrailingPadding: CGFloat = 8
414 }
415
416 private final class LineRow {
417 let gutterRow = UIView()
418 let gutterLabel = UILabel()
419 let codeRow = UIView()
420 let codeLabel = UILabel()
421 let gutterHeightConstraint: NSLayoutConstraint
422 let codeHeightConstraint: NSLayoutConstraint
423
424 init() {
425 gutterRow.translatesAutoresizingMaskIntoConstraints = false
426 gutterLabel.translatesAutoresizingMaskIntoConstraints = false
427 codeRow.translatesAutoresizingMaskIntoConstraints = false
428 codeLabel.translatesAutoresizingMaskIntoConstraints = false
429
430 gutterHeightConstraint = gutterRow.heightAnchor.constraint(equalToConstant: 0)
431 codeHeightConstraint = codeRow.heightAnchor.constraint(equalToConstant: 0)
432 }
433 }
434
435 private let outerScrollView = UIScrollView()
436 private let contentView = UIView()
437 private let gutterContainerView = UIView()
438 private let gutterStackView = UIStackView()
439 private let horizontalScrollView = UIScrollView()
440 private let codeContainerView = UIView()
441 private let codeStackView = UIStackView()
442
443 private var codeContainerWidthConstraint: NSLayoutConstraint?
444 private var codeContainerExplicitWidthConstraint: NSLayoutConstraint?
445 private var gutterWidthConstraint: NSLayoutConstraint?
446
447 private var rows: [LineRow] = []
448 private var currentText = ""
449 private var currentFileName = ""
450 private var currentFont: UIFont
451 private var wrapLines: Bool
452 private var needsLineLayoutUpdate = true
453 private var lastMeasuredCodeWidth: CGFloat = 0
454
455 init(text: String, fileName: String, font: UIFont, wrapLines: Bool) {
456 self.currentFont = font
457 self.wrapLines = wrapLines
458 super.init(frame: .zero)
459 setupViews()
460 updateContent(text: text, fileName: fileName, font: font, wrapLines: wrapLines)
461 }
462
463 @available(*, unavailable)
464 required init?(coder: NSCoder) {
465 fatalError("init(coder:) has not been implemented")
466 }
467
468 override func layoutSubviews() {
469 super.layoutSubviews()
470 updateLineLayoutsIfNeeded()
471 }
472
473 func updateContent(text: String, fileName: String, font: UIFont, wrapLines: Bool) {
474 let contentChanged = text != currentText || fileName != currentFileName || font != currentFont
475 let wrapChanged = wrapLines != self.wrapLines
476
477 currentText = text
478 currentFileName = fileName
479 currentFont = font
480 self.wrapLines = wrapLines
481
482 if contentChanged {
483 rebuildRows()
484 }
485
486 if contentChanged || wrapChanged {
487 updateWrapConfiguration(resetHorizontalOffset: wrapChanged)
488 needsLineLayoutUpdate = true
489 setNeedsLayout()
490 layoutIfNeeded()
491 }
492 }
493
494 private func setupViews() {
495 backgroundColor = .systemBackground
496
497 outerScrollView.translatesAutoresizingMaskIntoConstraints = false
498 contentView.translatesAutoresizingMaskIntoConstraints = false
499 gutterContainerView.translatesAutoresizingMaskIntoConstraints = false
500 gutterStackView.translatesAutoresizingMaskIntoConstraints = false
501 horizontalScrollView.translatesAutoresizingMaskIntoConstraints = false
502 codeContainerView.translatesAutoresizingMaskIntoConstraints = false
503 codeStackView.translatesAutoresizingMaskIntoConstraints = false
504
505 gutterStackView.axis = .vertical
506 gutterStackView.alignment = .fill
507 gutterStackView.distribution = .fill
508 gutterStackView.spacing = 0
509
510 codeStackView.axis = .vertical
511 codeStackView.alignment = .fill
512 codeStackView.distribution = .fill
513 codeStackView.spacing = 0
514
515 outerScrollView.alwaysBounceVertical = true
516 horizontalScrollView.alwaysBounceVertical = false
517 horizontalScrollView.showsVerticalScrollIndicator = false
518
519 addSubview(outerScrollView)
520 outerScrollView.addSubview(contentView)
521 contentView.addSubview(gutterContainerView)
522 contentView.addSubview(horizontalScrollView)
523 gutterContainerView.addSubview(gutterStackView)
524 horizontalScrollView.addSubview(codeContainerView)
525 codeContainerView.addSubview(codeStackView)
526
527 codeContainerWidthConstraint = codeContainerView.widthAnchor.constraint(equalTo: horizontalScrollView.frameLayoutGuide.widthAnchor)
528 codeContainerExplicitWidthConstraint = codeContainerView.widthAnchor.constraint(equalToConstant: 0)
529 gutterWidthConstraint = gutterContainerView.widthAnchor.constraint(equalToConstant: 0)
530
531 NSLayoutConstraint.activate([
532 outerScrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
533 outerScrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
534 outerScrollView.topAnchor.constraint(equalTo: topAnchor),
535 outerScrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
536
537 contentView.leadingAnchor.constraint(equalTo: outerScrollView.contentLayoutGuide.leadingAnchor),
538 contentView.trailingAnchor.constraint(equalTo: outerScrollView.contentLayoutGuide.trailingAnchor),
539 contentView.topAnchor.constraint(equalTo: outerScrollView.contentLayoutGuide.topAnchor),
540 contentView.bottomAnchor.constraint(equalTo: outerScrollView.contentLayoutGuide.bottomAnchor),
541 contentView.widthAnchor.constraint(equalTo: outerScrollView.frameLayoutGuide.widthAnchor),
542
543 gutterContainerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
544 gutterContainerView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: Layout.verticalPadding),
545 gutterContainerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -Layout.verticalPadding),
546
547 horizontalScrollView.leadingAnchor.constraint(equalTo: gutterContainerView.trailingAnchor),
548 horizontalScrollView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
549 horizontalScrollView.topAnchor.constraint(equalTo: gutterContainerView.topAnchor),
550 horizontalScrollView.bottomAnchor.constraint(equalTo: gutterContainerView.bottomAnchor),
551
552 gutterStackView.leadingAnchor.constraint(equalTo: gutterContainerView.leadingAnchor, constant: Layout.gutterLeadingPadding),
553 gutterStackView.trailingAnchor.constraint(equalTo: gutterContainerView.trailingAnchor, constant: -Layout.gutterTrailingPadding),
554 gutterStackView.topAnchor.constraint(equalTo: gutterContainerView.topAnchor),
555 gutterStackView.bottomAnchor.constraint(equalTo: gutterContainerView.bottomAnchor),
556
557 codeContainerView.leadingAnchor.constraint(equalTo: horizontalScrollView.contentLayoutGuide.leadingAnchor),
558 codeContainerView.trailingAnchor.constraint(equalTo: horizontalScrollView.contentLayoutGuide.trailingAnchor),
559 codeContainerView.topAnchor.constraint(equalTo: horizontalScrollView.contentLayoutGuide.topAnchor),
560 codeContainerView.bottomAnchor.constraint(equalTo: horizontalScrollView.contentLayoutGuide.bottomAnchor),
561 codeContainerView.heightAnchor.constraint(equalTo: horizontalScrollView.frameLayoutGuide.heightAnchor),
562
563 codeStackView.leadingAnchor.constraint(equalTo: codeContainerView.leadingAnchor),
564 codeStackView.trailingAnchor.constraint(equalTo: codeContainerView.trailingAnchor),
565 codeStackView.topAnchor.constraint(equalTo: codeContainerView.topAnchor),
566 codeStackView.bottomAnchor.constraint(equalTo: codeContainerView.bottomAnchor)
567 ])
568
569 gutterWidthConstraint?.isActive = true
570 }
571
572 private func rebuildRows() {
573 rows.forEach { row in
574 gutterStackView.removeArrangedSubview(row.gutterRow)
575 row.gutterRow.removeFromSuperview()
576 codeStackView.removeArrangedSubview(row.codeRow)
577 row.codeRow.removeFromSuperview()
578 }
579 rows.removeAll()
580
581 let attributedText = CodeSyntaxHighlighter.attributedText(
582 for: currentText,
583 fileName: currentFileName,
584 font: currentFont
585 )
586 let lines = makeLines(from: attributedText, font: currentFont)
587
588 gutterWidthConstraint?.constant = Self.gutterWidth(lineCount: lines.count, font: currentFont)
589
590 for line in lines {
591 let row = makeRow(for: line)
592 rows.append(row)
593 gutterStackView.addArrangedSubview(row.gutterRow)
594 codeStackView.addArrangedSubview(row.codeRow)
595 }
596 }
597
598 private func makeRow(for line: CodeFileLineData) -> LineRow {
599 let row = LineRow()
600
601 row.gutterLabel.font = currentFont
602 row.gutterLabel.textColor = .secondaryLabel
603 row.gutterLabel.textAlignment = .right
604 row.gutterLabel.text = line.number
605
606 row.codeLabel.attributedText = line.text
607 row.codeLabel.font = currentFont
608
609 row.gutterRow.addSubview(row.gutterLabel)
610 row.codeRow.addSubview(row.codeLabel)
611
612 NSLayoutConstraint.activate([
613 row.gutterHeightConstraint,
614 row.codeHeightConstraint,
615
616 row.gutterLabel.leadingAnchor.constraint(equalTo: row.gutterRow.leadingAnchor),
617 row.gutterLabel.trailingAnchor.constraint(equalTo: row.gutterRow.trailingAnchor),
618 row.gutterLabel.topAnchor.constraint(equalTo: row.gutterRow.topAnchor),
619 row.gutterLabel.bottomAnchor.constraint(lessThanOrEqualTo: row.gutterRow.bottomAnchor),
620
621 row.codeLabel.leadingAnchor.constraint(equalTo: row.codeRow.leadingAnchor),
622 row.codeLabel.trailingAnchor.constraint(equalTo: row.codeRow.trailingAnchor),
623 row.codeLabel.topAnchor.constraint(equalTo: row.codeRow.topAnchor),
624 row.codeLabel.bottomAnchor.constraint(lessThanOrEqualTo: row.codeRow.bottomAnchor)
625 ])
626
627 return row
628 }
629
630 private func updateWrapConfiguration(resetHorizontalOffset: Bool) {
631 horizontalScrollView.alwaysBounceHorizontal = !wrapLines
632 horizontalScrollView.isScrollEnabled = !wrapLines
633
634 if wrapLines {
635 codeContainerWidthConstraint?.isActive = true
636 codeContainerExplicitWidthConstraint?.isActive = false
637 } else {
638 codeContainerWidthConstraint?.isActive = false
639 codeContainerExplicitWidthConstraint?.isActive = true
640 }
641
642 for row in rows {
643 row.codeLabel.numberOfLines = wrapLines ? 0 : 1
644 row.codeLabel.lineBreakMode = wrapLines ? .byWordWrapping : .byClipping
645 }
646
647 if resetHorizontalOffset {
648 horizontalScrollView.setContentOffset(.zero, animated: false)
649 }
650 }
651
652 private func updateLineLayoutsIfNeeded() {
653 let availableWidth = max(horizontalScrollView.bounds.width, 0)
654 let shouldUpdateForWidth = wrapLines && abs(availableWidth - lastMeasuredCodeWidth) > 0.5
655
656 guard needsLineLayoutUpdate || shouldUpdateForWidth else { return }
657
658 lastMeasuredCodeWidth = availableWidth
659 let measurementWidth = wrapLines ? max(availableWidth, 1) : CGFloat.greatestFiniteMagnitude
660
661 for row in rows {
662 row.codeLabel.preferredMaxLayoutWidth = wrapLines ? measurementWidth : 0
663 let measuredSize = row.codeLabel.sizeThatFits(
664 CGSize(width: measurementWidth, height: CGFloat.greatestFiniteMagnitude)
665 )
666 let rowHeight = max(ceil(measuredSize.height), ceil(currentFont.lineHeight))
667 row.gutterHeightConstraint.constant = rowHeight
668 row.codeHeightConstraint.constant = rowHeight
669 }
670
671 if wrapLines {
672 codeContainerExplicitWidthConstraint?.constant = 0
673 } else {
674 let maxLineWidth = rows.reduce(CGFloat(0)) { partialResult, row in
675 let measuredWidth = row.codeLabel.sizeThatFits(
676 CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
677 ).width
678 return max(partialResult, ceil(measuredWidth))
679 }
680 codeContainerExplicitWidthConstraint?.constant = max(maxLineWidth, availableWidth)
681 }
682
683 needsLineLayoutUpdate = false
684 }
685
686 private func makeLines(from attributedText: NSAttributedString, font: UIFont) -> [CodeFileLineData] {
687 let string = attributedText.string as NSString
688 var lines: [CodeFileLineData] = []
689 var currentLocation = 0
690 var lineNumber = 1
691
692 while currentLocation < attributedText.length {
693 let searchRange = NSRange(location: currentLocation, length: attributedText.length - currentLocation)
694 let newlineRange = string.range(of: "\n", options: [], range: searchRange)
695 let lineRange: NSRange
696
697 if newlineRange.location == NSNotFound {
698 lineRange = searchRange
699 currentLocation = attributedText.length
700 } else {
701 lineRange = NSRange(location: currentLocation, length: newlineRange.location - currentLocation)
702 currentLocation = newlineRange.location + newlineRange.length
703 }
704
705 lines.append(CodeFileLineData(
706 number: String(lineNumber),
707 text: attributedText.attributedSubstring(from: lineRange)
708 ))
709 lineNumber += 1
710 }
711
712 if attributedText.length == 0 || string.hasSuffix("\n") {
713 lines.append(CodeFileLineData(
714 number: String(lineNumber),
715 text: NSAttributedString(string: "", attributes: [.font: font])
716 ))
717 }
718
719 return lines
720 }
721
722 private static func gutterWidth(lineCount: Int, font: UIFont) -> CGFloat {
723 let digits = String(max(lineCount, 1)).count
724 let sample = String(repeating: "8", count: digits)
725 let numberWidth = ceil((sample as NSString).size(withAttributes: [.font: font]).width)
726 return Layout.gutterLeadingPadding + numberWidth + Layout.gutterTrailingPadding
727 }
728}
729
730private struct CodeFileLineData {
731 let number: String
732 let text: NSAttributedString
733}
734
735private enum CodeSyntaxHighlighter {
736 static func attributedText(for text: String, fileName: String, font: UIFont) -> NSAttributedString {
737 guard supportsSplashHighlighting(fileName: fileName) else {
738 return plainText(text, font: font)
739 }
740
741 var splashFont = Font(size: Double(font.pointSize))
742 splashFont.resource = .preloaded(font)
743
744 let theme = Theme(
745 font: splashFont,
746 plainTextColor: .label,
747 tokenColors: [
748 .keyword: .systemPink,
749 .string: .systemRed,
750 .type: .systemTeal,
751 .call: .systemBlue,
752 .number: .systemPurple,
753 .comment: .secondaryLabel,
754 .property: .systemGreen,
755 .dotAccess: .systemIndigo,
756 .preprocessing: .systemOrange
757 ],
758 backgroundColor: .clear
759 )
760
761 let highlighted = SyntaxHighlighter(
762 format: AttributedStringOutputFormat(theme: theme)
763 ).highlight(text)
764
765 return NSMutableAttributedString(attributedString: highlighted)
766 }
767
768 private static func plainText(_ text: String, font: UIFont) -> NSAttributedString {
769 NSAttributedString(
770 string: text,
771 attributes: [
772 .font: font,
773 .foregroundColor: UIColor.label
774 ]
775 )
776 }
777
778 private static func supportsSplashHighlighting(fileName: String) -> Bool {
779 let supportedExtensions: Set<String> = [
780 "swift",
781 "swiftinterface",
782 "playground"
783 ]
784 return supportedExtensions.contains(
785 (fileName as NSString).pathExtension.lowercased()
786 )
787 }
788}
789
790// MARK: - Tree Entry Row
791
792private struct TreeEntryRow: View {
793 let entry: TreeEntry
794
795 var body: some View {
796 Label {
797 Text(entry.name)
798 .font(.body.monospaced())
799 .lineLimit(1)
800 } icon: {
801 Image(systemName: iconName)
802 .foregroundStyle(iconColor)
803 }
804 }
805
806 private var iconName: String {
807 switch entry.object {
808 case .tree: "folder.fill"
809 case .unknown: "questionmark.circle"
810 default: "doc"
811 }
812 }
813
814 private var iconColor: ViewColor {
815 switch entry.object {
816 case .tree: .blue
817 case .unknown: .orange
818 default: .secondary
819 }
820 }
821}
822
823// MARK: - Ref Picker Sheet
824
825private struct RefPickerSheet: View {
826 let viewModel: FileTreeViewModel
827 @Binding var isPresented: Bool
828
829 var body: some View {
830 NavigationStack {
831 List {
832 Section {
833 Button {
834 Task {
835 await viewModel.changeRevspec("HEAD")
836 isPresented = false
837 }
838 } label: {
839 refRow(
840 title: "HEAD",
841 systemImage: "arrow.triangle.branch",
842 color: .blue,
843 isSelected: viewModel.revspec == "HEAD"
844 )
845 }
846 .buttonStyle(.plain)
847 }
848
849 if !viewModel.branches.isEmpty {
850 Section("Branches") {
851 ForEach(viewModel.branches, 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/heads/", with: ""),
860 systemImage: "arrow.triangle.branch",
861 color: .blue,
862 isSelected: viewModel.revspec == ref.name
863 )
864 }
865 .buttonStyle(.plain)
866 }
867 }
868 }
869
870 if !viewModel.tags.isEmpty {
871 Section("Tags") {
872 ForEach(viewModel.tags, id: \.name) { ref in
873 Button {
874 Task {
875 await viewModel.changeRevspec(ref.name)
876 isPresented = false
877 }
878 } label: {
879 refRow(
880 title: ref.name.replacingOccurrences(of: "refs/tags/", with: ""),
881 systemImage: "tag",
882 color: .orange,
883 isSelected: viewModel.revspec == ref.name
884 )
885 }
886 .buttonStyle(.plain)
887 }
888 }
889 }
890 }
891 .listStyle(.insetGrouped)
892 .navigationTitle("Select Ref")
893 .navigationBarTitleDisplayMode(.inline)
894 .toolbar {
895 ToolbarItem(placement: .cancellationAction) {
896 Button("Cancel") {
897 isPresented = false
898 }
899 }
900 }
901 .overlay {
902 if viewModel.isLoadingRefs {
903 SRHTLoadingStateView(message: "Loading references…")
904 }
905 }
906 }
907 }
908
909 private func refRow(title: String, systemImage: String, color: ViewColor, isSelected: Bool) -> some View {
910 HStack(spacing: 12) {
911 Image(systemName: systemImage)
912 .foregroundStyle(color)
913
914 Text(title)
915 .font(.body.monospaced())
916 .foregroundStyle(.primary)
917
918 Spacer()
919
920 if isSelected {
921 Image(systemName: "checkmark")
922 .font(.caption.weight(.semibold))
923 .foregroundStyle(.tint)
924 }
925 }
926 .contentShape(Rectangle())
927 }
928}