krz/hutch

an ios client for sourcehut

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

v2.8.0: Hutch/Views/Repositories/FileTreeView.swift · raw

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