gitbay/Views/Repos/FileView.swift

v1.8.0
gitbay-ios/gitbay/Views/Repos/FileView.swift history · blame · raw

138 lines · 5265 bytes

  1import SwiftUI
  2
  3struct FileView: View {
  4
  5    @Environment(\.colorScheme) private var colorScheme
  6    @State private var model: FileViewModel
  7    @State private var editing = false
  8    @State private var draft = ""
  9
 10    init(client: GitbayClient, repo: String, path: String, ref: String?) {
 11        _model = State(initialValue: FileViewModel(
 12            client: client, repoPath: repo, filePath: path, ref: ref
 13        ))
 14    }
 15
 16    var body: some View {
 17        Group {
 18            if let file = model.state.value {
 19                content(file)
 20            } else {
 21                Color.clear
 22            }
 23        }
 24        .overlay { LoadStateOverlay(state: model.state) }
 25        .navigationTitle(model.fileName)
 26        .navigationBarTitleDisplayMode(.inline)
 27        .toolbar {
 28            ToolbarItem(placement: .topBarTrailing) {
 29                if model.state.value?.binary == false {
 30                    Menu {
 31                        NavigationLink(value: RepoRoute.log(
 32                            repo: model.repoPath, ref: model.ref, path: model.filePath
 33                        )) {
 34                            Label("History", systemImage: "clock")
 35                        }
 36                        NavigationLink(value: RepoRoute.blame(
 37                            repo: model.repoPath, path: model.filePath, ref: model.ref
 38                        )) {
 39                            Label("Blame", systemImage: "person.crop.rectangle.stack")
 40                        }
 41                        if let branch = model.editableBranch {
 42                            Button {
 43                                draft = model.state.value?.content ?? ""
 44                                editing = true
 45                            } label: {
 46                                Label("Edit on \(branch)", systemImage: "pencil")
 47                            }
 48                        }
 49                    } label: {
 50                        Image(systemName: "ellipsis.circle")
 51                    }
 52                    .accessibilityLabel("Actions")
 53                    .accessibilityIdentifier("file-actions-menu")
 54                }
 55            }
 56        }
 57        .sheet(isPresented: $editing) {
 58            if let branch = model.editableBranch {
 59                FileEditSheet(model: model, branch: branch, content: $draft) {
 60                    editing = false
 61                }
 62            }
 63        }
 64        .task { await model.load() }
 65    }
 66
 67    @ViewBuilder
 68    private func content(_ file: FileContent) -> some View {
 69        if file.binary {
 70            ContentUnavailableView {
 71                Label("Binary file", systemImage: "doc.zipper")
 72            } description: {
 73                Text("\(file.file) · \(Int64(file.size).formatted(.byteCount(style: .file)))")
 74            }
 75        } else if let text = file.content {
 76            VStack(spacing: 0) {
 77                if file.isTruncated {
 78                    GBNotice(
 79                        "Truncated — showing the first \(Int64(file.size).formatted(.byteCount(style: .file))).",
 80                        .gbWarn
 81                    )
 82                    .padding(.horizontal, 12)
 83                    .padding(.vertical, 6)
 84                }
 85                CodeScrollView(text: text, fileName: model.fileName, colorScheme: colorScheme)
 86            }
 87        }
 88    }
 89}
 90
 91/// Both-axis scrolling for code, highlighted when the language is known.
 92/// One Text per line in a lazy stack: a single Text of a long file (a
 93/// 2,700-line changelog) does not draw.
 94private struct CodeScrollView: View {
 95
 96    let text: String
 97    let fileName: String
 98    let colorScheme: ColorScheme
 99
100    @State private var lines: [AttributedString] = []
101
102    var body: some View {
103        ScrollView([.horizontal, .vertical]) {
104            LazyVStack(alignment: .leading, spacing: 0) {
105                ForEach(lines.indices, id: \.self) { index in
106                    Text(lines[index].characters.isEmpty ? " " : lines[index])
107                        .fixedSize()
108                }
109            }
110            .padding(12)
111        }
112        .defaultScrollAnchor(.topLeading)
113        .font(.gbMono(.caption))
114        .task(id: colorScheme) { lines = highlightedLines() }
115    }
116
117    private func highlightedLines() -> [AttributedString] {
118        let size = UIFont.preferredFont(forTextStyle: .caption1).pointSize
119        let font = UIFont(name: "Atkinson Hyperlegible Mono", size: size)
120            ?? .monospacedSystemFont(ofSize: size, weight: .regular)
121        let highlighter = SyntaxHighlighter(colorScheme: colorScheme)
122        return codeLines(highlighter.highlight(text, fileName: fileName, font: font)
123            ?? NSAttributedString(string: text))
124    }
125}
126
127/// A file's lines, each keeping its highlighting. A trailing newline ends
128/// the last line rather than starting an empty one.
129nonisolated func codeLines(_ source: NSAttributedString) -> [AttributedString] {
130    var lines: [AttributedString] = []
131    let string = source.string as NSString
132    string.enumerateSubstrings(
133        in: NSRange(location: 0, length: string.length), options: [.byLines, .substringNotRequired]
134    ) { _, range, _, _ in
135        lines.append(AttributedString(source.attributedSubstring(from: range)))
136    }
137    return lines
138}