krz/hutch

an ios client for sourcehut

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

e84e5543be9be548784859f4b082b9d7a3122f15

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-01T18:50:09Z

Rewrite file viewer with pinned gutter, syntax highlighting, and toolbar

Replace the SwiftUI-only CodeFileTextView with a UIViewRepresentable backed
by CodeFileUIView. The previous approach could not support a fixed line number
gutter while independently scrolling code content horizontally.

Changes:
- Gutter is pinned outside the horizontal scroll view and never scrolls left
- Vertical scroll is handled by a single outer UIScrollView shared by both
  the gutter and code content, keeping them in sync
- Line numbers are right-aligned in a fixed-width gutter calculated from
  the total line count
- Syntax highlighting via Splash for .swift files, with plain monospaced
  rendering as fallback for unsupported file types
- Wrap toggle persisted via AppStorage (wrapRepositoryFileLines)
- Bottom toolbar replaces the floating Share button, adding Copy All and
  Wrap toggle actions
- Copy All shows a brief checkmark confirmation that resets after 2 seconds
- Font fixed at SFMono-Regular 12pt, not scaled with Dynamic Type

Implements: https://todo.sr.ht/~ccleberg/Hutch/7
 Hutch.xcodeproj/project.pbxproj                    |  17 +
 .../xcshareddata/swiftpm/Package.resolved          |  11 +-
 Hutch/App/AppStorageKeys.swift                     |   1 +
 Hutch/Views/Repositories/FileTreeView.swift        | 566 +++++++++++++++++++--
 4 files changed, 544 insertions(+), 51 deletions(-)

diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index cb7bea9..d844cd3 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -16,6 +16,7 @@
 		8BE09E812F7B2CC300F88693 /* styles.css in Resources */ = {isa = PBXBuildFile; fileRef = 8BE09E7D2F7B2CC300F88693 /* styles.css */; };
 		8BE09E822F7B2CC300F88693 /* privacy.html in Resources */ = {isa = PBXBuildFile; fileRef = 8BE09E7B2F7B2CC300F88693 /* privacy.html */; };
 		8BF100032F9A000100000001 /* Markdown in Frameworks */ = {isa = PBXBuildFile; productRef = 8BF100022F9A000100000001 /* Markdown */; };
+		8BF100052F9A000100000002 /* Splash in Frameworks */ = {isa = PBXBuildFile; productRef = 8BF100042F9A000100000002 /* Splash */; };
 /* End PBXBuildFile section */
 
 /* Begin PBXContainerItemProxy section */
@@ -116,6 +117,7 @@
 			buildActionMask = 2147483647;
 			files = (
 				8BF100032F9A000100000001 /* Markdown in Frameworks */,
+				8BF100052F9A000100000002 /* Splash in Frameworks */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
@@ -198,6 +200,7 @@
 			name = Hutch;
 			packageProductDependencies = (
 				8BF100022F9A000100000001 /* Markdown */,
+				8BF100042F9A000100000002 /* Splash */,
 			);
 			productName = Hutch;
 			productReference = 8B4B28D12F6704280045FA19 /* Hutch.app */;
@@ -279,6 +282,7 @@
 			minimizedProjectReferenceProxies = 1;
 			packageReferences = (
 				8BF100012F9A000100000001 /* XCRemoteSwiftPackageReference "swift-markdown" */,
+				8BF100062F9A000100000002 /* XCRemoteSwiftPackageReference "Splash" */,
 			);
 			preferredProjectObjectVersion = 77;
 			productRefGroup = 8B4B28D22F6704280045FA19 /* Products */;
@@ -710,6 +714,14 @@
 				minimumVersion = 0.7.3;
 			};
 		};
+		8BF100062F9A000100000002 /* XCRemoteSwiftPackageReference "Splash" */ = {
+			isa = XCRemoteSwiftPackageReference;
+			repositoryURL = "https://github.com/JohnSundell/Splash";
+			requirement = {
+				kind = upToNextMajorVersion;
+				minimumVersion = 0.16.0;
+			};
+		};
 /* End XCRemoteSwiftPackageReference section */
 
 /* Begin XCSwiftPackageProductDependency section */
@@ -718,6 +730,11 @@
 			package = 8BF100012F9A000100000001 /* XCRemoteSwiftPackageReference "swift-markdown" */;
 			productName = Markdown;
 		};
+		8BF100042F9A000100000002 /* Splash */ = {
+			isa = XCSwiftPackageProductDependency;
+			package = 8BF100062F9A000100000002 /* XCRemoteSwiftPackageReference "Splash" */;
+			productName = Splash;
+		};
 /* End XCSwiftPackageProductDependency section */
 	};
 	rootObject = 8B4B28C92F6704280045FA19 /* Project object */;
diff --git a/Hutch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Hutch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index a2a45a3..ae6b756 100644
--- a/Hutch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/Hutch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -1,6 +1,15 @@
 {
-  "originHash" : "b809819531d430dc5b7a0fb1fb5538d0321c5d541eed52a9990f37c38dd0f98a",
+  "originHash" : "65542d8a5fbbad8dd7d07c8901c1cc48b3aba12cef8da1f062bd28d9b3a6ba50",
   "pins" : [
+    {
+      "identity" : "splash",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/JohnSundell/Splash",
+      "state" : {
+        "revision" : "7f4df436eb78fe64fe2c32c58006e9949fa28ad8",
+        "version" : "0.16.0"
+      }
+    },
     {
       "identity" : "swift-cmark",
       "kind" : "remoteSourceControl",
diff --git a/Hutch/App/AppStorageKeys.swift b/Hutch/App/AppStorageKeys.swift
index 708b4ce..87ec420 100644
--- a/Hutch/App/AppStorageKeys.swift
+++ b/Hutch/App/AppStorageKeys.swift
@@ -2,4 +2,5 @@
 enum AppStorageKeys {
     static let swipeActionsEnabled = "swipeActionsEnabled"
     static let activeAccountID = "activeAccountID"
+    static let wrapRepositoryFileLines = "wrapRepositoryFileLines"
 }
diff --git a/Hutch/Views/Repositories/FileTreeView.swift b/Hutch/Views/Repositories/FileTreeView.swift
index 830fc36..8bedb73 100644
--- a/Hutch/Views/Repositories/FileTreeView.swift
+++ b/Hutch/Views/Repositories/FileTreeView.swift
@@ -1,4 +1,8 @@
+import Splash
 import SwiftUI
+import UIKit
+
+private typealias ViewColor = SwiftUI.Color
 
 struct FileTreeView: View {
     let repository: RepositorySummary
@@ -9,7 +13,7 @@ struct FileTreeView: View {
     var body: some View {
         Group {
             if let viewModel {
-                FileTreeContentView(repository: repository, viewModel: viewModel)
+                FileTreeContentView(viewModel: viewModel)
             } else {
                 SRHTLoadingStateView(message: "Loading files…")
             }
@@ -33,10 +37,14 @@ struct FileTreeView: View {
 // MARK: - Content View
 
 private struct FileTreeContentView: View {
-    let repository: RepositorySummary
     let viewModel: FileTreeViewModel
 
+    @AppStorage(AppStorageKeys.wrapRepositoryFileLines) private var wrapRepositoryFileLines = false
     @State private var showRefPicker = false
+    @State private var showFileShareSheet = false
+    @State private var showShareUnavailableAlert = false
+    @State private var didCopyFileContents = false
+    @State private var copyResetTask: Task<Void, Never>?
 
     var body: some View {
         VStack(spacing: 0) {
@@ -64,20 +72,11 @@ private struct FileTreeContentView: View {
             get: { viewModel.error },
             set: { viewModel.error = $0 }
         ))
-        .refreshable {
-            await viewModel.loadRootTree()
+        .onChange(of: viewModel.viewingEntry?.name) { _, _ in
+            resetCopyConfirmation()
         }
     }
 
-    private var shareURL: URL? {
-        guard let viewingEntry = viewModel.viewingEntry else { return nil }
-        return SRHTWebURL.file(
-            repository: repository,
-            revspec: viewModel.revspec,
-            path: currentFilePath(for: viewingEntry)
-        )
-    }
-
     private var revspecLabel: String {
         let revspec = viewModel.revspec
         if revspec == "HEAD" {
@@ -138,13 +137,6 @@ private struct FileTreeContentView: View {
         .background(.bar)
     }
 
-    private func currentFilePath(for entry: TreeEntry) -> String {
-        let directoryComponents = viewModel.navStack
-            .dropFirst()
-            .map(\.name)
-        return (directoryComponents + [entry.name]).joined(separator: "/")
-    }
-
     // MARK: - Content Area
 
     @ViewBuilder
@@ -178,6 +170,33 @@ private struct FileTreeContentView: View {
         }
     }
 
+    private func shareFileContents(_ text: String) {
+        if text.isEmpty {
+            showShareUnavailableAlert = true
+        } else {
+            showFileShareSheet = true
+        }
+    }
+
+    private func copyFileContents(_ text: String) {
+        UIPasteboard.general.string = text
+        didCopyFileContents = true
+        copyResetTask?.cancel()
+        copyResetTask = Task {
+            try? await Task.sleep(for: .seconds(2))
+            guard !Task.isCancelled else { return }
+            await MainActor.run {
+                didCopyFileContents = false
+            }
+        }
+    }
+
+    private func resetCopyConfirmation() {
+        copyResetTask?.cancel()
+        copyResetTask = nil
+        didCopyFileContents = false
+    }
+
     // MARK: - File Content View
 
     @ViewBuilder
@@ -216,36 +235,34 @@ private struct FileTreeContentView: View {
                 }
         }
         .listStyle(.plain)
+        .refreshable {
+            await viewModel.loadRootTree()
+        }
     }
 
     // MARK: - Text Blob
 
     @ViewBuilder
     private func textBlobView(_ entry: TreeEntry, blob: GitTextBlob) -> some View {
+        let text = blob.text ?? ""
+
         VStack(spacing: 0) {
-            HStack {
-                Spacer()
-                SRHTShareButton(url: shareURL, target: .file) {
-                    Label("Share File", systemImage: "square.and.arrow.up")
-                }
-                .buttonStyle(.bordered)
-            }
-            .padding(.horizontal)
-            .padding(.top, 12)
-
-            GeometryReader { geometry in
-                ScrollView([.vertical, .horizontal]) {
-                    Text(blob.text ?? "")
-                        .font(.system(.body, design: .monospaced))
-                        .multilineTextAlignment(.leading)
-                        .fixedSize(horizontal: true, vertical: false)
-                        .frame(minWidth: geometry.size.width,
-                               minHeight: geometry.size.height,
-                               alignment: .topLeading)
-                        .padding()
-                }
-                .frame(width: geometry.size.width, height: geometry.size.height)
-            }
+            CodeFileTextView(
+                text: text,
+                fileName: entry.name,
+                wrapLines: wrapRepositoryFileLines
+            )
+        }
+        .safeAreaInset(edge: .bottom, spacing: 0) {
+            fileActionToolbar(text: text)
+        }
+        .sheet(isPresented: $showFileShareSheet) {
+            FileContentShareSheet(activityItems: [text])
+        }
+        .alert("Share Unavailable", isPresented: $showShareUnavailableAlert) {
+            Button("OK", role: .cancel) {}
+        } message: {
+            Text(SRHTShareTarget.file.fallbackMessage)
         }
     }
 
@@ -273,11 +290,6 @@ private struct FileTreeContentView: View {
                 .font(.subheadline)
                 .foregroundStyle(.tertiary)
 
-            SRHTShareButton(url: shareURL, target: .file) {
-                Label("Share File", systemImage: "square.and.arrow.up")
-            }
-            .buttonStyle(.bordered)
-
             if let content = blob.content, let url = URL(string: content) {
                 Link(destination: url) {
                     Label("Open in Safari", systemImage: "safari")
@@ -303,6 +315,460 @@ private struct FileTreeContentView: View {
         formatter.countStyle = .file
         return formatter.string(fromByteCount: Int64(bytes))
     }
+
+    private func fileActionToolbar(text: String) -> some View {
+        HStack(spacing: 0) {
+            toolbarButton(
+                title: "Share",
+                systemImage: "square.and.arrow.up"
+            ) {
+                shareFileContents(text)
+            }
+
+            toolbarButton(
+                title: didCopyFileContents ? "Copied" : "Copy All",
+                systemImage: didCopyFileContents ? "checkmark" : "doc.on.doc"
+            ) {
+                copyFileContents(text)
+            }
+
+            toolbarButton(
+                title: wrapRepositoryFileLines ? "Wrap On" : "Wrap Off",
+                systemImage: "text.word.spacing"
+            ) {
+                wrapRepositoryFileLines.toggle()
+            }
+        }
+        .padding(.horizontal, 8)
+        .padding(.top, 10)
+        .padding(.bottom, 8)
+        .background(.bar)
+        .overlay(alignment: .top) {
+            Divider()
+        }
+    }
+
+    private func toolbarButton(
+        title: String,
+        systemImage: String,
+        action: @escaping () -> Void
+    ) -> some View {
+        Button(action: action) {
+            VStack(spacing: 4) {
+                Image(systemName: systemImage)
+                    .font(.system(size: 17, weight: .semibold))
+                Text(title)
+                    .font(.caption2)
+                    .lineLimit(1)
+            }
+            .frame(maxWidth: .infinity)
+            .contentShape(Rectangle())
+        }
+        .buttonStyle(.plain)
+        .foregroundStyle(.primary)
+    }
+}
+
+private struct CodeFileTextView: UIViewRepresentable {
+    let text: String
+    let fileName: String
+    let wrapLines: Bool
+
+    private let font = UIFont(name: "SFMono-Regular", size: 12)
+        ?? UIFont.monospacedSystemFont(ofSize: 12, weight: .regular)
+
+    func makeUIView(context _: Context) -> CodeFileUIView {
+        CodeFileUIView(
+            text: text,
+            fileName: fileName,
+            font: font,
+            wrapLines: wrapLines
+        )
+    }
+
+    func updateUIView(_ uiView: CodeFileUIView, context _: Context) {
+        uiView.updateContent(
+            text: text,
+            fileName: fileName,
+            font: font,
+            wrapLines: wrapLines
+        )
+    }
+}
+
+private struct FileContentShareSheet: UIViewControllerRepresentable {
+    let activityItems: [Any]
+
+    func makeUIViewController(context _: Context) -> UIActivityViewController {
+        UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
+    }
+
+    func updateUIViewController(_ : UIActivityViewController, context _: Context) {}
+}
+
+private final class CodeFileUIView: UIView {
+    private enum Layout {
+        static let verticalPadding: CGFloat = 12
+        static let gutterLeadingPadding: CGFloat = 12
+        static let gutterTrailingPadding: CGFloat = 8
+    }
+
+    private final class LineRow {
+        let gutterRow = UIView()
+        let gutterLabel = UILabel()
+        let codeRow = UIView()
+        let codeLabel = UILabel()
+        let gutterHeightConstraint: NSLayoutConstraint
+        let codeHeightConstraint: NSLayoutConstraint
+
+        init() {
+            gutterRow.translatesAutoresizingMaskIntoConstraints = false
+            gutterLabel.translatesAutoresizingMaskIntoConstraints = false
+            codeRow.translatesAutoresizingMaskIntoConstraints = false
+            codeLabel.translatesAutoresizingMaskIntoConstraints = false
+
+            gutterHeightConstraint = gutterRow.heightAnchor.constraint(equalToConstant: 0)
+            codeHeightConstraint = codeRow.heightAnchor.constraint(equalToConstant: 0)
+        }
+    }
+
+    private let outerScrollView = UIScrollView()
+    private let contentView = UIView()
+    private let gutterContainerView = UIView()
+    private let gutterStackView = UIStackView()
+    private let horizontalScrollView = UIScrollView()
+    private let codeContainerView = UIView()
+    private let codeStackView = UIStackView()
+
+    private var codeContainerWidthConstraint: NSLayoutConstraint?
+    private var gutterWidthConstraint: NSLayoutConstraint?
+
+    private var rows: [LineRow] = []
+    private var currentText = ""
+    private var currentFileName = ""
+    private var currentFont: UIFont
+    private var wrapLines: Bool
+    private var needsLineLayoutUpdate = true
+    private var lastMeasuredCodeWidth: CGFloat = 0
+
+    init(text: String, fileName: String, font: UIFont, wrapLines: Bool) {
+        self.currentFont = font
+        self.wrapLines = wrapLines
+        super.init(frame: .zero)
+        setupViews()
+        updateContent(text: text, fileName: fileName, font: font, wrapLines: wrapLines)
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+
+    override func layoutSubviews() {
+        super.layoutSubviews()
+        updateLineLayoutsIfNeeded()
+    }
+
+    func updateContent(text: String, fileName: String, font: UIFont, wrapLines: Bool) {
+        let contentChanged = text != currentText || fileName != currentFileName || font != currentFont
+        let wrapChanged = wrapLines != self.wrapLines
+
+        currentText = text
+        currentFileName = fileName
+        currentFont = font
+        self.wrapLines = wrapLines
+
+        if contentChanged {
+            rebuildRows()
+        }
+
+        if contentChanged || wrapChanged {
+            updateWrapConfiguration(resetHorizontalOffset: wrapChanged)
+            needsLineLayoutUpdate = true
+            setNeedsLayout()
+            layoutIfNeeded()
+        }
+    }
+
+    private func setupViews() {
+        backgroundColor = .systemBackground
+
+        outerScrollView.translatesAutoresizingMaskIntoConstraints = false
+        contentView.translatesAutoresizingMaskIntoConstraints = false
+        gutterContainerView.translatesAutoresizingMaskIntoConstraints = false
+        gutterStackView.translatesAutoresizingMaskIntoConstraints = false
+        horizontalScrollView.translatesAutoresizingMaskIntoConstraints = false
+        codeContainerView.translatesAutoresizingMaskIntoConstraints = false
+        codeStackView.translatesAutoresizingMaskIntoConstraints = false
+
+        gutterStackView.axis = .vertical
+        gutterStackView.alignment = .fill
+        gutterStackView.distribution = .fill
+        gutterStackView.spacing = 0
+
+        codeStackView.axis = .vertical
+        codeStackView.alignment = .fill
+        codeStackView.distribution = .fill
+        codeStackView.spacing = 0
+
+        outerScrollView.alwaysBounceVertical = true
+        horizontalScrollView.alwaysBounceVertical = false
+        horizontalScrollView.showsVerticalScrollIndicator = false
+
+        addSubview(outerScrollView)
+        outerScrollView.addSubview(contentView)
+        contentView.addSubview(gutterContainerView)
+        contentView.addSubview(horizontalScrollView)
+        gutterContainerView.addSubview(gutterStackView)
+        horizontalScrollView.addSubview(codeContainerView)
+        codeContainerView.addSubview(codeStackView)
+
+        codeContainerWidthConstraint = codeContainerView.widthAnchor.constraint(equalTo: horizontalScrollView.frameLayoutGuide.widthAnchor)
+        gutterWidthConstraint = gutterContainerView.widthAnchor.constraint(equalToConstant: 0)
+
+        NSLayoutConstraint.activate([
+            outerScrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
+            outerScrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
+            outerScrollView.topAnchor.constraint(equalTo: topAnchor),
+            outerScrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
+
+            contentView.leadingAnchor.constraint(equalTo: outerScrollView.contentLayoutGuide.leadingAnchor),
+            contentView.trailingAnchor.constraint(equalTo: outerScrollView.contentLayoutGuide.trailingAnchor),
+            contentView.topAnchor.constraint(equalTo: outerScrollView.contentLayoutGuide.topAnchor),
+            contentView.bottomAnchor.constraint(equalTo: outerScrollView.contentLayoutGuide.bottomAnchor),
+            contentView.widthAnchor.constraint(equalTo: outerScrollView.frameLayoutGuide.widthAnchor),
+
+            gutterContainerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
+            gutterContainerView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: Layout.verticalPadding),
+            gutterContainerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -Layout.verticalPadding),
+
+            horizontalScrollView.leadingAnchor.constraint(equalTo: gutterContainerView.trailingAnchor),
+            horizontalScrollView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
+            horizontalScrollView.topAnchor.constraint(equalTo: gutterContainerView.topAnchor),
+            horizontalScrollView.bottomAnchor.constraint(equalTo: gutterContainerView.bottomAnchor),
+
+            gutterStackView.leadingAnchor.constraint(equalTo: gutterContainerView.leadingAnchor, constant: Layout.gutterLeadingPadding),
+            gutterStackView.trailingAnchor.constraint(equalTo: gutterContainerView.trailingAnchor, constant: -Layout.gutterTrailingPadding),
+            gutterStackView.topAnchor.constraint(equalTo: gutterContainerView.topAnchor),
+            gutterStackView.bottomAnchor.constraint(equalTo: gutterContainerView.bottomAnchor),
+
+            codeContainerView.leadingAnchor.constraint(equalTo: horizontalScrollView.contentLayoutGuide.leadingAnchor),
+            codeContainerView.trailingAnchor.constraint(equalTo: horizontalScrollView.contentLayoutGuide.trailingAnchor),
+            codeContainerView.topAnchor.constraint(equalTo: horizontalScrollView.contentLayoutGuide.topAnchor),
+            codeContainerView.bottomAnchor.constraint(equalTo: horizontalScrollView.contentLayoutGuide.bottomAnchor),
+            codeContainerView.heightAnchor.constraint(equalTo: horizontalScrollView.frameLayoutGuide.heightAnchor),
+
+            codeStackView.leadingAnchor.constraint(equalTo: codeContainerView.leadingAnchor),
+            codeStackView.trailingAnchor.constraint(equalTo: codeContainerView.trailingAnchor),
+            codeStackView.topAnchor.constraint(equalTo: codeContainerView.topAnchor),
+            codeStackView.bottomAnchor.constraint(equalTo: codeContainerView.bottomAnchor)
+        ])
+
+        gutterWidthConstraint?.isActive = true
+    }
+
+    private func rebuildRows() {
+        rows.forEach { row in
+            gutterStackView.removeArrangedSubview(row.gutterRow)
+            row.gutterRow.removeFromSuperview()
+            codeStackView.removeArrangedSubview(row.codeRow)
+            row.codeRow.removeFromSuperview()
+        }
+        rows.removeAll()
+
+        let attributedText = CodeSyntaxHighlighter.attributedText(
+            for: currentText,
+            fileName: currentFileName,
+            font: currentFont
+        )
+        let lines = makeLines(from: attributedText, font: currentFont)
+
+        gutterWidthConstraint?.constant = Self.gutterWidth(lineCount: lines.count, font: currentFont)
+
+        for line in lines {
+            let row = makeRow(for: line)
+            rows.append(row)
+            gutterStackView.addArrangedSubview(row.gutterRow)
+            codeStackView.addArrangedSubview(row.codeRow)
+        }
+    }
+
+    private func makeRow(for line: CodeFileLineData) -> LineRow {
+        let row = LineRow()
+
+        row.gutterLabel.font = currentFont
+        row.gutterLabel.textColor = .secondaryLabel
+        row.gutterLabel.textAlignment = .right
+        row.gutterLabel.text = line.number
+
+        row.codeLabel.attributedText = line.text
+        row.codeLabel.font = currentFont
+
+        row.gutterRow.addSubview(row.gutterLabel)
+        row.codeRow.addSubview(row.codeLabel)
+
+        NSLayoutConstraint.activate([
+            row.gutterHeightConstraint,
+            row.codeHeightConstraint,
+
+            row.gutterLabel.leadingAnchor.constraint(equalTo: row.gutterRow.leadingAnchor),
+            row.gutterLabel.trailingAnchor.constraint(equalTo: row.gutterRow.trailingAnchor),
+            row.gutterLabel.topAnchor.constraint(equalTo: row.gutterRow.topAnchor),
+            row.gutterLabel.bottomAnchor.constraint(lessThanOrEqualTo: row.gutterRow.bottomAnchor),
+
+            row.codeLabel.leadingAnchor.constraint(equalTo: row.codeRow.leadingAnchor),
+            row.codeLabel.trailingAnchor.constraint(equalTo: row.codeRow.trailingAnchor),
+            row.codeLabel.topAnchor.constraint(equalTo: row.codeRow.topAnchor),
+            row.codeLabel.bottomAnchor.constraint(lessThanOrEqualTo: row.codeRow.bottomAnchor)
+        ])
+
+        return row
+    }
+
+    private func updateWrapConfiguration(resetHorizontalOffset: Bool) {
+        horizontalScrollView.alwaysBounceHorizontal = !wrapLines
+        horizontalScrollView.isScrollEnabled = true
+
+        if wrapLines {
+            codeContainerWidthConstraint?.isActive = true
+        } else {
+            codeContainerWidthConstraint?.isActive = false
+        }
+
+        for row in rows {
+            row.codeLabel.numberOfLines = wrapLines ? 0 : 1
+            row.codeLabel.lineBreakMode = wrapLines ? .byWordWrapping : .byClipping
+        }
+
+        if resetHorizontalOffset {
+            horizontalScrollView.setContentOffset(.zero, animated: false)
+        }
+    }
+
+    private func updateLineLayoutsIfNeeded() {
+        let availableWidth = max(horizontalScrollView.bounds.width, 0)
+        let shouldUpdateForWidth = wrapLines && abs(availableWidth - lastMeasuredCodeWidth) > 0.5
+
+        guard needsLineLayoutUpdate || shouldUpdateForWidth else { return }
+
+        lastMeasuredCodeWidth = availableWidth
+        let measurementWidth = wrapLines ? max(availableWidth, 1) : CGFloat.greatestFiniteMagnitude
+
+        for row in rows {
+            row.codeLabel.preferredMaxLayoutWidth = wrapLines ? measurementWidth : 0
+            let measuredSize = row.codeLabel.sizeThatFits(
+                CGSize(width: measurementWidth, height: CGFloat.greatestFiniteMagnitude)
+            )
+            let rowHeight = max(ceil(measuredSize.height), ceil(currentFont.lineHeight))
+            row.gutterHeightConstraint.constant = rowHeight
+            row.codeHeightConstraint.constant = rowHeight
+        }
+
+        needsLineLayoutUpdate = false
+    }
+
+    private func makeLines(from attributedText: NSAttributedString, font: UIFont) -> [CodeFileLineData] {
+        let string = attributedText.string as NSString
+        var lines: [CodeFileLineData] = []
+        var currentLocation = 0
+        var lineNumber = 1
+
+        while currentLocation < attributedText.length {
+            let searchRange = NSRange(location: currentLocation, length: attributedText.length - currentLocation)
+            let newlineRange = string.range(of: "\n", options: [], range: searchRange)
+            let lineRange: NSRange
+
+            if newlineRange.location == NSNotFound {
+                lineRange = searchRange
+                currentLocation = attributedText.length
+            } else {
+                lineRange = NSRange(location: currentLocation, length: newlineRange.location - currentLocation)
+                currentLocation = newlineRange.location + newlineRange.length
+            }
+
+            lines.append(CodeFileLineData(
+                number: String(lineNumber),
+                text: attributedText.attributedSubstring(from: lineRange)
+            ))
+            lineNumber += 1
+        }
+
+        if attributedText.length == 0 || string.hasSuffix("\n") {
+            lines.append(CodeFileLineData(
+                number: String(lineNumber),
+                text: NSAttributedString(string: "", attributes: [.font: font])
+            ))
+        }
+
+        return lines
+    }
+
+    private static func gutterWidth(lineCount: Int, font: UIFont) -> CGFloat {
+        let digits = String(max(lineCount, 1)).count
+        let sample = String(repeating: "8", count: digits)
+        let numberWidth = ceil((sample as NSString).size(withAttributes: [.font: font]).width)
+        return Layout.gutterLeadingPadding + numberWidth + Layout.gutterTrailingPadding
+    }
+}
+
+private struct CodeFileLineData {
+    let number: String
+    let text: NSAttributedString
+}
+
+private enum CodeSyntaxHighlighter {
+    static func attributedText(for text: String, fileName: String, font: UIFont) -> NSAttributedString {
+        guard supportsSplashHighlighting(fileName: fileName) else {
+            return plainText(text, font: font)
+        }
+
+        var splashFont = Font(size: Double(font.pointSize))
+        splashFont.resource = .preloaded(font)
+
+        let theme = Theme(
+            font: splashFont,
+            plainTextColor: .label,
+            tokenColors: [
+                .keyword: .systemPink,
+                .string: .systemRed,
+                .type: .systemTeal,
+                .call: .systemBlue,
+                .number: .systemPurple,
+                .comment: .secondaryLabel,
+                .property: .systemGreen,
+                .dotAccess: .systemIndigo,
+                .preprocessing: .systemOrange
+            ],
+            backgroundColor: .clear
+        )
+
+        let highlighted = SyntaxHighlighter(
+            format: AttributedStringOutputFormat(theme: theme)
+        ).highlight(text)
+
+        return NSMutableAttributedString(attributedString: highlighted)
+    }
+
+    private static func plainText(_ text: String, font: UIFont) -> NSAttributedString {
+        NSAttributedString(
+            string: text,
+            attributes: [
+                .font: font,
+                .foregroundColor: UIColor.label
+            ]
+        )
+    }
+
+    private static func supportsSplashHighlighting(fileName: String) -> Bool {
+        let supportedExtensions: Set<String> = [
+            "swift",
+            "swiftinterface",
+            "playground"
+        ]
+        return supportedExtensions.contains(
+            (fileName as NSString).pathExtension.lowercased()
+        )
+    }
 }
 
 // MARK: - Tree Entry Row
@@ -329,7 +795,7 @@ private struct TreeEntryRow: View {
         }
     }
 
-    private var iconColor: Color {
+    private var iconColor: ViewColor {
         switch entry.object {
         case .tree: .blue
         case .unknown: .orange
@@ -424,7 +890,7 @@ private struct RefPickerSheet: View {
         }
     }
 
-    private func refRow(title: String, systemImage: String, color: Color, isSelected: Bool) -> some View {
+    private func refRow(title: String, systemImage: String, color: ViewColor, isSelected: Bool) -> some View {
         HStack(spacing: 12) {
             Image(systemName: systemImage)
                 .foregroundStyle(color)