import SwiftUI import UIKit /// One file from a repository: an image, a line-numbered text view with /// optional Markdown preview, or a placeholder for binary content. struct FileView: View { var repo: RepoSummary var ref: String? var path: String @Environment(AppModel.self) private var model @State private var state: LoadState = .idle @State private var wrapLines = false @State private var showPreview = false private var fileName: String { URL(fileURLWithPath: path).lastPathComponent } private var isMarkdown: Bool { ["md", "markdown"].contains(URL(fileURLWithPath: path).pathExtension.lowercased()) } /// The decoded text, once loaded, for copy, share and preview. private var loadedText: String? { guard case .loaded(let file) = state, case .text(let text, _) = file.kind else { return nil } return text } var body: some View { Group { if repo.repoDID == nil { RepoNotMigratedView() } else { LoadStateView(state: state, retry: { await load() }) { file in fileContent(file) } } } .navigationTitle(fileName) .navigationBarTitleDisplayMode(.inline) .toolbar { toolbarItems } .task { await load() } } @ViewBuilder private func fileContent(_ file: LoadedFile) -> some View { switch file.kind { case .image(let image): ZoomableImage(image: image) case .text(let text, let lines): if showPreview && isMarkdown { ScrollView { MarkdownText(text) .padding() } } else { CodeLines( lines: lines, language: file.size < 300_000 ? SyntaxHighlighter.language(forPath: path) : "text", wrap: wrapLines ) } case .binary: ContentUnavailableView( "Binary file", systemImage: "doc.questionmark", description: Text(ByteCountFormatter.string(fromByteCount: Int64(file.size), countStyle: .file)) ) } } @ToolbarContentBuilder private var toolbarItems: some ToolbarContent { if let text = loadedText { ToolbarItem(placement: .topBarTrailing) { Menu { Toggle("Wrap Lines", systemImage: "text.alignleft", isOn: $wrapLines) if isMarkdown { Toggle("Markdown Preview", systemImage: "doc.richtext", isOn: $showPreview) } Button("Copy Contents", systemImage: "doc.on.doc") { UIPasteboard.general.string = text } ShareLink(item: text) { Label("Share", systemImage: "square.and.arrow.up") } } label: { Label("More", systemImage: "ellipsis") } } } } private func load() async { guard let repoDID = repo.repoDID else { return } do { let data = try await model.bobbin.blob(repoDID: repoDID, ref: ref, path: path) state = .loaded(LoadedFile.make(data: data, path: path)) } catch { state = .failed(error) } } } // MARK: - Loaded content private struct LoadedFile { enum Kind { case image(UIImage) case text(String, lines: [String]) case binary } var kind: Kind var size: Int private static let imageExtensions: Set = ["png", "jpg", "jpeg", "gif", "webp", "heic"] static func make(data: Data, path: String) -> LoadedFile { let ext = URL(fileURLWithPath: path).pathExtension.lowercased() if imageExtensions.contains(ext), let image = UIImage(data: data) { return LoadedFile(kind: .image(image), size: data.count) } // Treat as binary if there is a NUL in the first 8 KB or the bytes are not UTF-8. guard !data.prefix(8192).contains(0), let text = String(data: data, encoding: .utf8) else { return LoadedFile(kind: .binary, size: data.count) } return LoadedFile(kind: .text(text, lines: lines(of: text)), size: data.count) } private static func lines(of text: String) -> [String] { var lines = text.components(separatedBy: "\n") if lines.last == "" { lines.removeLast() } return lines.map { $0.hasSuffix("\r") ? String($0.dropLast()) : $0 } } } // MARK: - Views /// Line-numbered code. Rows are lazy, so very long files stay cheap. private struct CodeLines: View { var lines: [String] var language: String var wrap: Bool @ScaledMetric(relativeTo: .footnote) private var fontSize: CGFloat = 13 private var numberWidth: CGFloat { CGFloat(String(lines.count).count) * charWidth + 4 } /// Width of one character in the monospaced code font. private var charWidth: CGFloat { let font = UIFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) return ("0" as NSString).size(withAttributes: [.font: font]).width } /// Unwrapped rows are given this width up front. A lazy stack only /// measures the rows it has realised, so without it long lines further /// down overflow the scroll view's content and can't be scrolled to. private var unwrappedWidth: CGFloat { let longest = displayLines.lazy.map(\.count).max() ?? 0 return numberWidth + 12 + CGFloat(longest + 1) * charWidth + 24 } /// Longer lines (usually minified files) are cut off with a marker. private static let maxLineLength = 2_000 /// Tabs render at an unpredictable width, so they're expanded here. private var displayLines: [String] { lines.map { line in let expanded = line.replacingOccurrences(of: "\t", with: " ") return expanded.count > Self.maxLineLength ? expanded.prefix(Self.maxLineLength) + "…" : expanded } } var body: some View { let shown = displayLines GeometryReader { proxy in if wrap { ScrollView(.vertical) { code(shown) .frame(minHeight: proxy.size.height, alignment: .top) } } else { ScrollView([.horizontal, .vertical]) { code(shown) .frame(width: max(unwrappedWidth, proxy.size.width), alignment: .leading) // A short file would otherwise sit centred in the scroll view. .frame(minHeight: proxy.size.height, alignment: .top) } } } } private func code(_ shown: [String]) -> some View { LazyVStack(alignment: .leading, spacing: 0) { ForEach(shown.indices, id: \.self) { index in HStack(alignment: .firstTextBaseline, spacing: 12) { Text(String(index + 1)) .foregroundStyle(.secondary) .frame(width: numberWidth, alignment: .trailing) Text(SyntaxHighlighter.highlight(line: shown[index], language: language)) .lineLimit(wrap ? nil : 1) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) } } } .font(.system(size: fontSize, design: .monospaced)) .padding(.horizontal, 12) .padding(.vertical, 8) } } /// An image that can be pinch-zoomed. private struct ZoomableImage: View { var image: UIImage @State private var scale: CGFloat = 1 @State private var baseScale: CGFloat = 1 var body: some View { GeometryReader { proxy in ScrollView([.horizontal, .vertical]) { Image(uiImage: image) .resizable() .scaledToFit() .frame(width: proxy.size.width * scale) .frame(maxWidth: .infinity) } .simultaneousGesture( MagnifyGesture() .onChanged { value in scale = min(max(baseScale * value.magnification, 1), 8) } .onEnded { _ in baseScale = scale } ) } } }