gitbay/Views/Repos/FileView.swift

36835f99e6d463f3e18c5c3fc3562bb92513f52b
gitbay-ios/gitbay/Views/Repos/FileView.swift history · blame · raw

195 lines · 8131 bytes

7 symbols in this file
  1import SwiftUI
  2
  3struct FileView: View {
  4
  5    @Environment(\.colorScheme) private var colorScheme
  6    @Environment(\.pushRoute) private var pushRoute
  7    @State private var model: FileViewModel
  8    @State private var editing = false
  9    @State private var draft = ""
 10    /// The line to open at, 1-based: a definition, or a search match.
 11    private let line: Int?
 12
 13    init(client: GitbayClient, repo: String, path: String, ref: String?, line: Int? = nil) {
 14        _model = State(initialValue: FileViewModel(
 15            client: client, repoPath: repo, filePath: path, ref: ref
 16        ))
 17        self.line = line
 18    }
 19
 20    var body: some View {
 21        Group {
 22            if let file = model.state.value {
 23                content(file)
 24            } else {
 25                Color.clear
 26            }
 27        }
 28        .overlay { LoadStateOverlay(state: model.state) }
 29        .navigationTitle(model.fileName)
 30        .navigationBarTitleDisplayMode(.inline)
 31        .toolbar {
 32            ToolbarItem(placement: .topBarTrailing) {
 33                if model.state.value?.binary == false {
 34                    Menu {
 35                        NavigationLink(value: RepoRoute.log(
 36                            repo: model.repoPath, ref: model.ref, path: model.filePath
 37                        )) {
 38                            Label("History", systemImage: "clock")
 39                        }
 40                        NavigationLink(value: RepoRoute.blame(
 41                            repo: model.repoPath, path: model.filePath, ref: model.ref
 42                        )) {
 43                            Label("Blame", systemImage: "person.crop.rectangle.stack")
 44                        }
 45                        if let branch = model.editableBranch {
 46                            Button {
 47                                draft = model.state.value?.content ?? ""
 48                                editing = true
 49                            } label: {
 50                                Label("Edit on \(branch)", systemImage: "pencil")
 51                            }
 52                        }
 53                    } label: {
 54                        Image(systemName: "ellipsis.circle")
 55                    }
 56                    .accessibilityLabel("Actions")
 57                    .accessibilityIdentifier("file-actions-menu")
 58                }
 59            }
 60        }
 61        .sheet(isPresented: $editing) {
 62            if let branch = model.editableBranch {
 63                FileEditSheet(model: model, branch: branch, content: $draft) {
 64                    editing = false
 65                }
 66            }
 67        }
 68        .task { await model.load() }
 69    }
 70
 71    @ViewBuilder
 72    private func content(_ file: FileContent) -> some View {
 73        if file.binary {
 74            ContentUnavailableView {
 75                Label("Binary file", systemImage: "doc.zipper")
 76            } description: {
 77                Text("\(file.file) · \(Int64(file.size).formatted(.byteCount(style: .file)))")
 78            }
 79        } else if let text = file.content {
 80            VStack(spacing: 0) {
 81                if file.isTruncated {
 82                    GBNotice(
 83                        "Truncated — showing the first \(Int64(file.size).formatted(.byteCount(style: .file))).",
 84                        .gbWarn
 85                    )
 86                    .padding(.horizontal, 12)
 87                    .padding(.vertical, 6)
 88                }
 89                CodeScrollView(text: text, fileName: model.fileName, colorScheme: colorScheme, line: line,
 90                               definitions: model.definitions)
 91                    .environment(\.openURL, OpenURLAction { url in
 92                        guard let name = Definition.name(in: url), let def = model.definitions[name] else {
 93                            return .systemAction
 94                        }
 95                        // Defined once: that line, at the ref on screen, as the
 96                        // web links it. More often: the search, to choose.
 97                        // repo symbols takes two characters at least, so a
 98                        // one-letter name defined more often opens its first.
 99                        if def.count == 1 || name.count < 2 {
100                            pushRoute(RepoRoute.file(repo: model.repoPath, path: def.path, ref: model.ref, line: def.line))
101                        } else {
102                            pushRoute(RepoRoute.symbols(repo: model.repoPath, query: name))
103                        }
104                        return .handled
105                    })
106            }
107        }
108    }
109}
110
111/// Both-axis scrolling for code, highlighted when the language is known.
112/// One Text per line in a lazy stack: a single Text of a long file (a
113/// 2,700-line changelog) does not draw.
114private struct CodeScrollView: View {
115
116    let text: String
117    let fileName: String
118    let colorScheme: ColorScheme
119    var line: Int?
120    var definitions: [String: Definition] = [:]
121
122    @State private var lines: [AttributedString] = []
123
124    var body: some View {
125        ScrollViewReader { proxy in
126            ScrollView([.horizontal, .vertical]) {
127                LazyVStack(alignment: .leading, spacing: 0) {
128                    // The row's identity is its 0-based index, the one
129                    // scrollTo names: a lazy row not yet drawn has no other.
130                    ForEach(lines.indices, id: \.self) { index in
131                        let target = index + 1 == line
132                        // Linked as the row is drawn, so a long file costs
133                        // only the rows on screen.
134                        let shown = definitions.isEmpty ? lines[index] : Definition.link(lines[index], definitions: definitions)
135                        Text(shown.characters.isEmpty ? " " : shown)
136                            .fixedSize()
137                            .frame(maxWidth: .infinity, alignment: .leading)
138                            .background(target ? Color.gbMark.opacity(0.18) : .clear)
139                            .modifier(TargetLine(on: target))
140                    }
141                }
142                .padding(12)
143            }
144            .defaultScrollAnchor(.topLeading)
145            .font(.gbMono(.caption))
146            .task(id: colorScheme) {
147                lines = highlightedLines()
148                if let line, line <= lines.count {
149                    // A frame for the highlighted lines to exist before the
150                    // jump, or the lazy stack has nothing to scroll to.
151                    await Task.yield()
152                    proxy.scrollTo(line - 1, anchor: UnitPoint(x: 0, y: 0.3))
153                }
154            }
155        }
156    }
157
158    private func highlightedLines() -> [AttributedString] {
159        let size = UIFont.preferredFont(forTextStyle: .caption1).pointSize
160        let font = UIFont(name: "Atkinson Hyperlegible Mono", size: size)
161            ?? .monospacedSystemFont(ofSize: size, weight: .regular)
162        let highlighter = SyntaxHighlighter(colorScheme: colorScheme)
163        return codeLines(highlighter.highlight(text, fileName: fileName, font: font)
164            ?? NSAttributedString(string: text))
165    }
166}
167
168/// The line a definition or a match opened at: named for VoiceOver and
169/// for UI tests, and left alone on every other row.
170private struct TargetLine: ViewModifier {
171    let on: Bool
172
173    func body(content: Content) -> some View {
174        if on {
175            content
176                .accessibilityIdentifier("file-target-line")
177                .accessibilityAddTraits(.isSelected)
178        } else {
179            content
180        }
181    }
182}
183
184/// A file's lines, each keeping its highlighting. A trailing newline ends
185/// the last line rather than starting an empty one.
186nonisolated func codeLines(_ source: NSAttributedString) -> [AttributedString] {
187    var lines: [AttributedString] = []
188    let string = source.string as NSString
189    string.enumerateSubstrings(
190        in: NSRange(location: 0, length: string.length), options: [.byLines, .substringNotRequired]
191    ) { _, range, _, _ in
192        lines.append(AttributedString(source.attributedSubstring(from: range)))
193    }
194    return lines
195}