gitbay/Views/Repos/FileView.swift
195 lines · 8131 bytes
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}