krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v2.12.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}