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