import SwiftUI struct FileView: View { @Environment(\.colorScheme) private var colorScheme @State private var model: FileViewModel @State private var editing = false @State private var draft = "" init(client: GitbayClient, repo: String, path: String, ref: String?) { _model = State(initialValue: FileViewModel( client: client, repoPath: repo, filePath: path, ref: ref )) } var body: some View { Group { if let file = model.state.value { content(file) } else { Color.clear } } .overlay { LoadStateOverlay(state: model.state) } .navigationTitle(model.fileName) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { if model.state.value?.binary == false { Menu { NavigationLink(value: RepoRoute.log( repo: model.repoPath, ref: model.ref, path: model.filePath )) { Label("History", systemImage: "clock") } NavigationLink(value: RepoRoute.blame( repo: model.repoPath, path: model.filePath, ref: model.ref )) { Label("Blame", systemImage: "person.crop.rectangle.stack") } if let branch = model.editableBranch { Button { draft = model.state.value?.content ?? "" editing = true } label: { Label("Edit on \(branch)", systemImage: "pencil") } } } label: { Image(systemName: "ellipsis.circle") } .accessibilityLabel("Actions") .accessibilityIdentifier("file-actions-menu") } } } .sheet(isPresented: $editing) { if let branch = model.editableBranch { FileEditSheet(model: model, branch: branch, content: $draft) { editing = false } } } .task { await model.load() } } @ViewBuilder private func content(_ file: FileContent) -> some View { if file.binary { ContentUnavailableView { Label("Binary file", systemImage: "doc.zipper") } description: { Text("\(file.file) · \(Int64(file.size).formatted(.byteCount(style: .file)))") } } else if let text = file.content { VStack(spacing: 0) { if file.isTruncated { GBNotice( "Truncated — showing the first \(Int64(file.size).formatted(.byteCount(style: .file))).", .gbWarn ) .padding(.horizontal, 12) .padding(.vertical, 6) } CodeScrollView(text: text, fileName: model.fileName, colorScheme: colorScheme) } } } } /// Both-axis scrolling for code, highlighted when the language is known. /// One Text per line in a lazy stack: a single Text of a long file (a /// 2,700-line changelog) does not draw. private struct CodeScrollView: View { let text: String let fileName: String let colorScheme: ColorScheme @State private var lines: [AttributedString] = [] var body: some View { ScrollView([.horizontal, .vertical]) { LazyVStack(alignment: .leading, spacing: 0) { ForEach(lines.indices, id: \.self) { index in Text(lines[index].characters.isEmpty ? " " : lines[index]) .fixedSize() } } .padding(12) } .defaultScrollAnchor(.topLeading) .font(.gbMono(.caption)) .task(id: colorScheme) { lines = highlightedLines() } } private func highlightedLines() -> [AttributedString] { let size = UIFont.preferredFont(forTextStyle: .caption1).pointSize let font = UIFont(name: "Atkinson Hyperlegible Mono", size: size) ?? .monospacedSystemFont(ofSize: size, weight: .regular) let highlighter = SyntaxHighlighter(colorScheme: colorScheme) return codeLines(highlighter.highlight(text, fileName: fileName, font: font) ?? NSAttributedString(string: text)) } } /// A file's lines, each keeping its highlighting. A trailing newline ends /// the last line rather than starting an empty one. nonisolated func codeLines(_ source: NSAttributedString) -> [AttributedString] { var lines: [AttributedString] = [] let string = source.string as NSString string.enumerateSubstrings( in: NSRange(location: 0, length: string.length), options: [.byLines, .substringNotRequired] ) { _, range, _, _ in lines.append(AttributedString(source.attributedSubstring(from: range))) } return lines }