gitbay/Views/Repos/FileView.swift
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}