krz/hutch

an ios client for sourcehut

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

v3.4.0: Hutch/Views/Repositories/FileTreeView.swift · raw

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