krz/hutch

an ios client for sourcehut

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

v3.9.0: 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    private var syntaxHighlighter: SyntaxHighlighter?
460    private var syntaxHighlighterTheme: SyntaxHighlightTheme?
461
462    init(text: String, fileName: String, font: UIFont, wrapLines: Bool) {
463        self.currentFont = font
464        self.wrapLines = wrapLines
465        super.init(frame: .zero)
466        setupViews()
467        registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: CodeFileUIView, _: UITraitCollection) in
468            view.rebuildRows()
469            view.needsLineLayoutUpdate = true
470            view.setNeedsLayout()
471        }
472        updateContent(text: text, fileName: fileName, font: font, wrapLines: wrapLines)
473    }
474
475    @available(*, unavailable)
476    required init?(coder _: NSCoder) {
477        fatalError("init(coder:) has not been implemented")
478    }
479
480    override func layoutSubviews() {
481        super.layoutSubviews()
482        updateLineLayoutsIfNeeded()
483    }
484
485    func updateContent(text: String, fileName: String, font: UIFont, wrapLines: Bool) {
486        let contentChanged = text != currentText || fileName != currentFileName || font != currentFont
487        let wrapChanged = wrapLines != self.wrapLines
488
489        currentText = text
490        currentFileName = fileName
491        currentFont = font
492        self.wrapLines = wrapLines
493
494        if contentChanged {
495            rebuildRows()
496        }
497
498        if contentChanged || wrapChanged {
499            updateWrapConfiguration(resetHorizontalOffset: wrapChanged)
500            needsLineLayoutUpdate = true
501            setNeedsLayout()
502            layoutIfNeeded()
503        }
504    }
505
506    private func setupViews() {
507        backgroundColor = .systemBackground
508
509        outerScrollView.translatesAutoresizingMaskIntoConstraints = false
510        contentView.translatesAutoresizingMaskIntoConstraints = false
511        gutterContainerView.translatesAutoresizingMaskIntoConstraints = false
512        gutterStackView.translatesAutoresizingMaskIntoConstraints = false
513        horizontalScrollView.translatesAutoresizingMaskIntoConstraints = false
514        codeContainerView.translatesAutoresizingMaskIntoConstraints = false
515        codeStackView.translatesAutoresizingMaskIntoConstraints = false
516
517        gutterStackView.axis = .vertical
518        gutterStackView.alignment = .fill
519        gutterStackView.distribution = .fill
520        gutterStackView.spacing = 0
521
522        codeStackView.axis = .vertical
523        codeStackView.alignment = .fill
524        codeStackView.distribution = .fill
525        codeStackView.spacing = 0
526
527        outerScrollView.alwaysBounceVertical = true
528        horizontalScrollView.alwaysBounceVertical = false
529        horizontalScrollView.showsVerticalScrollIndicator = false
530
531        addSubview(outerScrollView)
532        outerScrollView.addSubview(contentView)
533        contentView.addSubview(gutterContainerView)
534        contentView.addSubview(horizontalScrollView)
535        gutterContainerView.addSubview(gutterStackView)
536        horizontalScrollView.addSubview(codeContainerView)
537        codeContainerView.addSubview(codeStackView)
538
539        codeContainerWidthConstraint = codeContainerView.widthAnchor.constraint(equalTo: horizontalScrollView.frameLayoutGuide.widthAnchor)
540        codeContainerExplicitWidthConstraint = codeContainerView.widthAnchor.constraint(equalToConstant: 0)
541        gutterWidthConstraint = gutterContainerView.widthAnchor.constraint(equalToConstant: 0)
542
543        NSLayoutConstraint.activate([
544            outerScrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
545            outerScrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
546            outerScrollView.topAnchor.constraint(equalTo: topAnchor),
547            outerScrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
548
549            contentView.leadingAnchor.constraint(equalTo: outerScrollView.contentLayoutGuide.leadingAnchor),
550            contentView.trailingAnchor.constraint(equalTo: outerScrollView.contentLayoutGuide.trailingAnchor),
551            contentView.topAnchor.constraint(equalTo: outerScrollView.contentLayoutGuide.topAnchor),
552            contentView.bottomAnchor.constraint(equalTo: outerScrollView.contentLayoutGuide.bottomAnchor),
553            contentView.widthAnchor.constraint(equalTo: outerScrollView.frameLayoutGuide.widthAnchor),
554
555            gutterContainerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
556            gutterContainerView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: Layout.verticalPadding),
557            gutterContainerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -Layout.verticalPadding),
558
559            horizontalScrollView.leadingAnchor.constraint(equalTo: gutterContainerView.trailingAnchor),
560            horizontalScrollView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
561            horizontalScrollView.topAnchor.constraint(equalTo: gutterContainerView.topAnchor),
562            horizontalScrollView.bottomAnchor.constraint(equalTo: gutterContainerView.bottomAnchor),
563
564            gutterStackView.leadingAnchor.constraint(equalTo: gutterContainerView.leadingAnchor, constant: Layout.gutterLeadingPadding),
565            gutterStackView.trailingAnchor.constraint(equalTo: gutterContainerView.trailingAnchor, constant: -Layout.gutterTrailingPadding),
566            gutterStackView.topAnchor.constraint(equalTo: gutterContainerView.topAnchor),
567            gutterStackView.bottomAnchor.constraint(equalTo: gutterContainerView.bottomAnchor),
568
569            codeContainerView.leadingAnchor.constraint(equalTo: horizontalScrollView.contentLayoutGuide.leadingAnchor),
570            codeContainerView.trailingAnchor.constraint(equalTo: horizontalScrollView.contentLayoutGuide.trailingAnchor),
571            codeContainerView.topAnchor.constraint(equalTo: horizontalScrollView.contentLayoutGuide.topAnchor),
572            codeContainerView.bottomAnchor.constraint(equalTo: horizontalScrollView.contentLayoutGuide.bottomAnchor),
573            codeContainerView.heightAnchor.constraint(equalTo: horizontalScrollView.frameLayoutGuide.heightAnchor),
574
575            codeStackView.leadingAnchor.constraint(equalTo: codeContainerView.leadingAnchor),
576            codeStackView.trailingAnchor.constraint(equalTo: codeContainerView.trailingAnchor),
577            codeStackView.topAnchor.constraint(equalTo: codeContainerView.topAnchor),
578            codeStackView.bottomAnchor.constraint(equalTo: codeContainerView.bottomAnchor)
579        ])
580
581        gutterWidthConstraint?.isActive = true
582    }
583
584    private func rebuildRows() {
585        rows.forEach { row in
586            gutterStackView.removeArrangedSubview(row.gutterRow)
587            row.gutterRow.removeFromSuperview()
588            codeStackView.removeArrangedSubview(row.codeRow)
589            row.codeRow.removeFromSuperview()
590        }
591        rows.removeAll()
592
593        let attributedText = highlightedAttributedText()
594        let lines = makeLines(from: attributedText, font: currentFont)
595
596        gutterWidthConstraint?.constant = Self.gutterWidth(lineCount: lines.count, font: currentFont)
597
598        for line in lines {
599            let row = makeRow(for: line)
600            rows.append(row)
601            gutterStackView.addArrangedSubview(row.gutterRow)
602            codeStackView.addArrangedSubview(row.codeRow)
603        }
604    }
605
606    /// Highlighting cap: above this size, skip tokenizing to keep the main
607    /// thread responsive and render plain, label-colored text instead.
608    private static let maxHighlightableLength = 100_000
609
610    private func highlightedAttributedText() -> NSAttributedString {
611        let plain = NSAttributedString(
612            string: currentText,
613            attributes: [.font: currentFont, .foregroundColor: UIColor.label]
614        )
615
616        guard currentText.count <= Self.maxHighlightableLength,
617              let language = SyntaxHighlighter.language(forFileName: currentFileName) else {
618            return plain
619        }
620
621        let theme = SyntaxHighlightTheme(userInterfaceStyle: traitCollection.userInterfaceStyle)
622        if syntaxHighlighterTheme != theme || syntaxHighlighter == nil {
623            syntaxHighlighter = SyntaxHighlighter(theme: theme)
624            syntaxHighlighterTheme = theme
625        }
626
627        return syntaxHighlighter?.attributedText(for: currentText, language: language, font: currentFont) ?? plain
628    }
629
630    private func makeRow(for line: CodeFileLineData) -> LineRow {
631        let row = LineRow()
632
633        row.gutterLabel.font = currentFont
634        row.gutterLabel.textColor = .secondaryLabel
635        row.gutterLabel.textAlignment = .right
636        row.gutterLabel.text = line.number
637
638        row.codeLabel.attributedText = line.text
639        row.codeLabel.font = currentFont
640
641        row.gutterRow.addSubview(row.gutterLabel)
642        row.codeRow.addSubview(row.codeLabel)
643
644        NSLayoutConstraint.activate([
645            row.gutterHeightConstraint,
646            row.codeHeightConstraint,
647
648            row.gutterLabel.leadingAnchor.constraint(equalTo: row.gutterRow.leadingAnchor),
649            row.gutterLabel.trailingAnchor.constraint(equalTo: row.gutterRow.trailingAnchor),
650            row.gutterLabel.topAnchor.constraint(equalTo: row.gutterRow.topAnchor),
651            row.gutterLabel.bottomAnchor.constraint(lessThanOrEqualTo: row.gutterRow.bottomAnchor),
652
653            row.codeLabel.leadingAnchor.constraint(equalTo: row.codeRow.leadingAnchor),
654            row.codeLabel.trailingAnchor.constraint(equalTo: row.codeRow.trailingAnchor),
655            row.codeLabel.topAnchor.constraint(equalTo: row.codeRow.topAnchor),
656            row.codeLabel.bottomAnchor.constraint(lessThanOrEqualTo: row.codeRow.bottomAnchor)
657        ])
658
659        return row
660    }
661
662    private func updateWrapConfiguration(resetHorizontalOffset: Bool) {
663        horizontalScrollView.alwaysBounceHorizontal = !wrapLines
664        horizontalScrollView.isScrollEnabled = !wrapLines
665
666        if wrapLines {
667            codeContainerWidthConstraint?.isActive = true
668            codeContainerExplicitWidthConstraint?.isActive = false
669        } else {
670            codeContainerWidthConstraint?.isActive = false
671            codeContainerExplicitWidthConstraint?.isActive = true
672        }
673
674        for row in rows {
675            row.codeLabel.numberOfLines = wrapLines ? 0 : 1
676            row.codeLabel.lineBreakMode = wrapLines ? .byWordWrapping : .byClipping
677        }
678
679        if resetHorizontalOffset {
680            horizontalScrollView.setContentOffset(.zero, animated: false)
681        }
682    }
683
684    private func updateLineLayoutsIfNeeded() {
685        let availableWidth = max(horizontalScrollView.bounds.width, 0)
686        let shouldUpdateForWidth = wrapLines && abs(availableWidth - lastMeasuredCodeWidth) > 0.5
687
688        guard needsLineLayoutUpdate || shouldUpdateForWidth else { return }
689
690        lastMeasuredCodeWidth = availableWidth
691        let measurementWidth = wrapLines ? max(availableWidth, 1) : CGFloat.greatestFiniteMagnitude
692
693        for row in rows {
694            row.codeLabel.preferredMaxLayoutWidth = wrapLines ? measurementWidth : 0
695            let measuredSize = row.codeLabel.sizeThatFits(
696                CGSize(width: measurementWidth, height: CGFloat.greatestFiniteMagnitude)
697            )
698            let rowHeight = max(ceil(measuredSize.height), ceil(currentFont.lineHeight))
699            row.gutterHeightConstraint.constant = rowHeight
700            row.codeHeightConstraint.constant = rowHeight
701        }
702
703        if wrapLines {
704            codeContainerExplicitWidthConstraint?.constant = 0
705        } else {
706            let maxLineWidth = rows.reduce(CGFloat(0)) { partialResult, row in
707                let measuredWidth = row.codeLabel.sizeThatFits(
708                    CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
709                ).width
710                return max(partialResult, ceil(measuredWidth))
711            }
712            codeContainerExplicitWidthConstraint?.constant = max(maxLineWidth, availableWidth)
713        }
714
715        needsLineLayoutUpdate = false
716    }
717
718    private func makeLines(from attributedText: NSAttributedString, font: UIFont) -> [CodeFileLineData] {
719        let string = attributedText.string as NSString
720        var lines: [CodeFileLineData] = []
721        var currentLocation = 0
722        var lineNumber = 1
723
724        while currentLocation < attributedText.length {
725            let searchRange = NSRange(location: currentLocation, length: attributedText.length - currentLocation)
726            let newlineRange = string.range(of: "\n", options: [], range: searchRange)
727            let lineRange: NSRange
728
729            if newlineRange.location == NSNotFound {
730                lineRange = searchRange
731                currentLocation = attributedText.length
732            } else {
733                lineRange = NSRange(location: currentLocation, length: newlineRange.location - currentLocation)
734                currentLocation = newlineRange.location + newlineRange.length
735            }
736
737            lines.append(CodeFileLineData(
738                number: String(lineNumber),
739                text: attributedText.attributedSubstring(from: lineRange)
740            ))
741            lineNumber += 1
742        }
743
744        if attributedText.length == 0 || string.hasSuffix("\n") {
745            lines.append(CodeFileLineData(
746                number: String(lineNumber),
747                text: NSAttributedString(string: "", attributes: [.font: font])
748            ))
749        }
750
751        return lines
752    }
753
754    private static func gutterWidth(lineCount: Int, font: UIFont) -> CGFloat {
755        let digits = String(max(lineCount, 1)).count
756        let sample = String(repeating: "8", count: digits)
757        let numberWidth = ceil((sample as NSString).size(withAttributes: [.font: font]).width)
758        return Layout.gutterLeadingPadding + numberWidth + Layout.gutterTrailingPadding
759    }
760}
761
762private struct CodeFileLineData {
763    let number: String
764    let text: NSAttributedString
765}
766
767// MARK: - Tree Entry Row
768
769private struct TreeEntryRow: View {
770    let entry: TreeEntry
771
772    var body: some View {
773        Label {
774            Text(entry.name)
775                .font(.body.monospaced())
776                .lineLimit(1)
777        } icon: {
778            Image(systemName: iconName)
779                .foregroundStyle(iconColor)
780        }
781    }
782
783    private var iconName: String {
784        switch entry.object {
785        case .tree: "folder.fill"
786        case .unknown: "questionmark.circle"
787        default: "doc"
788        }
789    }
790
791    private var iconColor: ViewColor {
792        switch entry.object {
793        case .tree: .blue
794        case .unknown: .orange
795        default: .secondary
796        }
797    }
798}
799
800// MARK: - Ref Picker Sheet
801
802private struct RefPickerSheet: View {
803    let viewModel: FileTreeViewModel
804    @Binding var isPresented: Bool
805
806    var body: some View {
807        NavigationStack {
808            List {
809                Section {
810                    Button {
811                        Task {
812                            await viewModel.changeRevspec("HEAD")
813                            isPresented = false
814                        }
815                    } label: {
816                        refRow(
817                            title: "HEAD",
818                            systemImage: "arrow.triangle.branch",
819                            color: .blue,
820                            isSelected: viewModel.revspec == "HEAD"
821                        )
822                    }
823                    .buttonStyle(.plain)
824                    .themedRow()
825                }
826
827                if !viewModel.branches.isEmpty {
828                    Section("Branches") {
829                        ForEach(viewModel.branches, id: \.name) { ref in
830                            Button {
831                                Task {
832                                    await viewModel.changeRevspec(ref.name)
833                                    isPresented = false
834                                }
835                            } label: {
836                                refRow(
837                                    title: ref.name.replacingOccurrences(of: "refs/heads/", with: ""),
838                                    systemImage: "arrow.triangle.branch",
839                                    color: .blue,
840                                    isSelected: viewModel.revspec == ref.name
841                                )
842                            }
843                            .buttonStyle(.plain)
844                        }
845                        .themedRow()
846                    }
847                }
848
849                if !viewModel.tags.isEmpty {
850                    Section("Tags") {
851                        ForEach(viewModel.tags, id: \.name) { ref in
852                            Button {
853                                Task {
854                                    await viewModel.changeRevspec(ref.name)
855                                    isPresented = false
856                                }
857                            } label: {
858                                refRow(
859                                    title: ref.name.replacingOccurrences(of: "refs/tags/", with: ""),
860                                    systemImage: "tag",
861                                    color: .orange,
862                                    isSelected: viewModel.revspec == ref.name
863                                )
864                            }
865                            .buttonStyle(.plain)
866                        }
867                        .themedRow()
868                    }
869                }
870            }
871            .themedList()
872            .listStyle(.insetGrouped)
873            .navigationTitle("Select Ref")
874            .navigationBarTitleDisplayMode(.inline)
875            .toolbar {
876                ToolbarItem(placement: .cancellationAction) {
877                    Button("Cancel") {
878                        isPresented = false
879                    }
880                }
881            }
882            .overlay {
883                if viewModel.isLoadingRefs {
884                    SRHTLoadingStateView(message: "Loading references…")
885                }
886            }
887        }
888    }
889
890    private func refRow(title: String, systemImage: String, color: ViewColor, isSelected: Bool) -> some View {
891        HStack(spacing: 12) {
892            Image(systemName: systemImage)
893                .foregroundStyle(color)
894
895            Text(title)
896                .font(.body.monospaced())
897                .foregroundStyle(.primary)
898
899            Spacer()
900
901            if isSelected {
902                Image(systemName: "checkmark")
903                    .font(.caption.weight(.semibold))
904                    .foregroundStyle(.tint)
905            }
906        }
907        .contentShape(Rectangle())
908    }
909}