import SwiftUI struct FileView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.pushRoute) private var pushRoute @State private var model: FileViewModel @State private var editing = false @State private var draft = "" /// The line to open at, 1-based: a definition, or a search match. private let line: Int? init(client: GitbayClient, repo: String, path: String, ref: String?, line: Int? = nil) { _model = State(initialValue: FileViewModel( client: client, repoPath: repo, filePath: path, ref: ref )) self.line = line } 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, line: line, definitions: model.definitions) .environment(\.openURL, OpenURLAction { url in guard let name = Definition.name(in: url), let def = model.definitions[name] else { return .systemAction } // Defined once: that line, at the ref on screen, as the // web links it. More often: the search, to choose. // repo symbols takes two characters at least, so a // one-letter name defined more often opens its first. if def.count == 1 || name.count < 2 { pushRoute(RepoRoute.file(repo: model.repoPath, path: def.path, ref: model.ref, line: def.line)) } else { pushRoute(RepoRoute.symbols(repo: model.repoPath, query: name)) } return .handled }) } } } } /// 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 var line: Int? var definitions: [String: Definition] = [:] @State private var lines: [AttributedString] = [] var body: some View { ScrollViewReader { proxy in ScrollView([.horizontal, .vertical]) { LazyVStack(alignment: .leading, spacing: 0) { // The row's identity is its 0-based index, the one // scrollTo names: a lazy row not yet drawn has no other. ForEach(lines.indices, id: \.self) { index in let target = index + 1 == line // Linked as the row is drawn, so a long file costs // only the rows on screen. let shown = definitions.isEmpty ? lines[index] : Definition.link(lines[index], definitions: definitions) Text(shown.characters.isEmpty ? " " : shown) .fixedSize() .frame(maxWidth: .infinity, alignment: .leading) .background(target ? Color.gbMark.opacity(0.18) : .clear) .modifier(TargetLine(on: target)) } } .padding(12) } .defaultScrollAnchor(.topLeading) .font(.gbMono(.caption)) .task(id: colorScheme) { lines = highlightedLines() if let line, line <= lines.count { // A frame for the highlighted lines to exist before the // jump, or the lazy stack has nothing to scroll to. await Task.yield() proxy.scrollTo(line - 1, anchor: UnitPoint(x: 0, y: 0.3)) } } } } 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)) } } /// The line a definition or a match opened at: named for VoiceOver and /// for UI tests, and left alone on every other row. private struct TargetLine: ViewModifier { let on: Bool func body(content: Content) -> some View { if on { content .accessibilityIdentifier("file-target-line") .accessibilityAddTraits(.isSelected) } else { content } } } /// 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 }