Something went wrong. Try again.
tangled on ur phone
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245import SwiftUIimport 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<LoadedFile> = .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<String> = ["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 } ) } }}