krz/hutch

an ios client for sourcehut

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

remove-splash-highlighter: Hutch/Views/Repositories/FileTreeView.swift · raw

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