krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.3.1: Hutch/Views/Repositories/FileTreeView.swift · raw
1import SwiftUI
2
3struct FileTreeView: View {
4 let repository: RepositorySummary
5 let client: SRHTClient
6
7 @State private var viewModel: FileTreeViewModel?
8
9 var body: some View {
10 Group {
11 if let viewModel {
12 FileTreeContentView(repository: repository, viewModel: viewModel)
13 } else {
14 SRHTLoadingStateView(message: "Loading files…")
15 }
16 }
17 .task {
18 if viewModel == nil {
19 let vm = FileTreeViewModel(
20 repositoryRid: repository.rid,
21 service: repository.service,
22 client: client
23 )
24 viewModel = vm
25 async let loadTree: () = vm.loadRootTree()
26 async let loadRefs: () = vm.loadReferences()
27 _ = await (loadTree, loadRefs)
28 }
29 }
30 }
31}
32
33// MARK: - Content View
34
35private struct FileTreeContentView: View {
36 let repository: RepositorySummary
37 let viewModel: FileTreeViewModel
38
39 @State private var showRefPicker = false
40
41 var body: some View {
42 VStack(spacing: 0) {
43 breadcrumbBar
44 Divider()
45 contentArea
46 }
47 .toolbar {
48 ToolbarItem(placement: .topBarTrailing) {
49 Button {
50 showRefPicker = true
51 } label: {
52 Label(
53 revspecLabel,
54 systemImage: "arrow.triangle.branch"
55 )
56 .font(.subheadline)
57 }
58 }
59 }
60 .sheet(isPresented: $showRefPicker) {
61 RefPickerSheet(viewModel: viewModel, isPresented: $showRefPicker)
62 }
63 .srhtErrorBanner(error: Binding(
64 get: { viewModel.error },
65 set: { viewModel.error = $0 }
66 ))
67 .refreshable {
68 await viewModel.loadRootTree()
69 }
70 }
71
72 private var shareURL: URL? {
73 guard let viewingEntry = viewModel.viewingEntry else { return nil }
74 return SRHTWebURL.file(
75 repository: repository,
76 revspec: viewModel.revspec,
77 path: currentFilePath(for: viewingEntry)
78 )
79 }
80
81 private var revspecLabel: String {
82 let revspec = viewModel.revspec
83 if revspec == "HEAD" {
84 return "HEAD"
85 }
86 if revspec.hasPrefix("refs/heads/") {
87 return String(revspec.dropFirst("refs/heads/".count))
88 } else if revspec.hasPrefix("refs/tags/") {
89 return String(revspec.dropFirst("refs/tags/".count))
90 }
91 return revspec
92 }
93
94 // MARK: - Breadcrumb Bar
95
96 private var breadcrumbBar: some View {
97 ScrollView(.horizontal, showsIndicators: false) {
98 HStack(spacing: 12) {
99 HStack(spacing: 4) {
100 ForEach(Array(viewModel.navStack.enumerated()), id: \.offset) { index, navEntry in
101 if index > 0 {
102 Image(systemName: "chevron.right")
103 .font(.caption2)
104 .foregroundStyle(.tertiary)
105 }
106
107 Button {
108 Task {
109 await viewModel.navigateToBreadcrumb(at: index)
110 }
111 } label: {
112 Text(navEntry.name)
113 .font(.subheadline.monospaced())
114 .foregroundStyle(
115 index == viewModel.navStack.count - 1 && viewModel.viewingEntry == nil
116 ? .primary : .secondary
117 )
118 }
119 .buttonStyle(.plain)
120 }
121
122 if let viewing = viewModel.viewingEntry {
123 Image(systemName: "chevron.right")
124 .font(.caption2)
125 .foregroundStyle(.tertiary)
126
127 Text(viewing.name)
128 .font(.subheadline.monospaced())
129 .foregroundStyle(.primary)
130 }
131 }
132
133 Spacer(minLength: 0)
134 }
135 .padding(.horizontal)
136 .padding(.vertical, 8)
137 }
138 .background(.bar)
139 }
140
141 private func currentFilePath(for entry: TreeEntry) -> String {
142 let directoryComponents = viewModel.navStack
143 .dropFirst()
144 .map(\.name)
145 return (directoryComponents + [entry.name]).joined(separator: "/")
146 }
147
148 // MARK: - Content Area
149
150 @ViewBuilder
151 private var contentArea: some View {
152 if viewModel.isLoading, viewModel.entries.isEmpty, viewModel.viewingEntry == nil {
153 SRHTLoadingStateView(message: "Loading files…")
154 } else if let entry = viewModel.viewingEntry, let object = viewModel.viewingObject {
155 // Viewing a file
156 fileContentView(entry: entry, object: object)
157 } else if let error = viewModel.error, viewModel.entries.isEmpty {
158 SRHTErrorStateView(
159 title: "Couldn't Load Files",
160 message: error,
161 retryAction: { await viewModel.loadRootTree() }
162 )
163 } else if !viewModel.entries.isEmpty {
164 // Viewing a directory listing
165 treeListView
166 } else if viewModel.navStack.isEmpty {
167 ContentUnavailableView(
168 "No Files",
169 systemImage: "folder",
170 description: Text("This repository could not be loaded.")
171 )
172 } else {
173 ContentUnavailableView(
174 "Empty Directory",
175 systemImage: "folder",
176 description: Text("This directory has no files.")
177 )
178 }
179 }
180
181 // MARK: - File Content View
182
183 @ViewBuilder
184 private func fileContentView(entry: TreeEntry, object: GitObject) -> some View {
185 switch object {
186 case .textBlob(let blob):
187 textBlobView(entry, blob: blob)
188 case .binaryBlob(let blob):
189 binaryBlobView(entry: entry, blob: blob)
190 default:
191 ContentUnavailableView(
192 "Unknown Object",
193 systemImage: "questionmark.folder",
194 description: Text("Cannot display this object type.")
195 )
196 }
197 }
198
199 // MARK: - Tree List
200
201 private var treeListView: some View {
202 let sorted = viewModel.entries.sorted { a, b in
203 let aIsTree = a.object?.isTree == true
204 let bIsTree = b.object?.isTree == true
205 if aIsTree != bIsTree { return aIsTree }
206 return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending
207 }
208
209 return List(sorted) { entry in
210 TreeEntryRow(entry: entry)
211 .contentShape(Rectangle())
212 .onTapGesture {
213 Task {
214 await viewModel.navigateInto(entry: entry)
215 }
216 }
217 }
218 .listStyle(.plain)
219 }
220
221 // MARK: - Text Blob
222
223 @ViewBuilder
224 private func textBlobView(_ entry: TreeEntry, blob: GitTextBlob) -> some View {
225 VStack(spacing: 0) {
226 HStack {
227 Spacer()
228 SRHTShareButton(url: shareURL, target: .file) {
229 Label("Share File", systemImage: "square.and.arrow.up")
230 }
231 .buttonStyle(.bordered)
232 }
233 .padding(.horizontal)
234 .padding(.top, 12)
235
236 GeometryReader { geometry in
237 ScrollView([.vertical, .horizontal]) {
238 Text(blob.text ?? "")
239 .font(.system(.body, design: .monospaced))
240 .multilineTextAlignment(.leading)
241 .fixedSize(horizontal: true, vertical: false)
242 .frame(minWidth: geometry.size.width,
243 minHeight: geometry.size.height,
244 alignment: .topLeading)
245 .padding()
246 }
247 .frame(width: geometry.size.width, height: geometry.size.height)
248 }
249 }
250 }
251
252 // MARK: - Binary Blob
253
254 @ViewBuilder
255 private func binaryBlobView(entry: TreeEntry, blob: GitBinaryBlob) -> some View {
256 VStack(spacing: 16) {
257 Spacer()
258
259 Image(systemName: "doc.zipper")
260 .font(.system(size: 48))
261 .foregroundStyle(.secondary)
262
263 Text(entry.name)
264 .font(.headline)
265
266 if let size = blob.size {
267 Text(formatBytes(size))
268 .font(.subheadline)
269 .foregroundStyle(.secondary)
270 }
271
272 Text("Binary file — cannot be displayed inline.")
273 .font(.subheadline)
274 .foregroundStyle(.tertiary)
275
276 SRHTShareButton(url: shareURL, target: .file) {
277 Label("Share File", systemImage: "square.and.arrow.up")
278 }
279 .buttonStyle(.bordered)
280
281 if let content = blob.content, let url = URL(string: content) {
282 Link(destination: url) {
283 Label("Open in Safari", systemImage: "safari")
284 }
285 .buttonStyle(.borderedProminent)
286 }
287
288 Button {
289 viewModel.dismissFileView()
290 } label: {
291 Text("Back to directory")
292 }
293
294 Spacer()
295 }
296 .frame(maxWidth: .infinity)
297 }
298
299 // MARK: - Helpers
300
301 private func formatBytes(_ bytes: Int) -> String {
302 let formatter = ByteCountFormatter()
303 formatter.countStyle = .file
304 return formatter.string(fromByteCount: Int64(bytes))
305 }
306}
307
308// MARK: - Tree Entry Row
309
310private struct TreeEntryRow: View {
311 let entry: TreeEntry
312
313 var body: some View {
314 Label {
315 Text(entry.name)
316 .font(.body.monospaced())
317 .lineLimit(1)
318 } icon: {
319 Image(systemName: iconName)
320 .foregroundStyle(iconColor)
321 }
322 }
323
324 private var iconName: String {
325 switch entry.object {
326 case .tree: "folder.fill"
327 case .unknown: "questionmark.circle"
328 default: "doc"
329 }
330 }
331
332 private var iconColor: Color {
333 switch entry.object {
334 case .tree: .blue
335 case .unknown: .orange
336 default: .secondary
337 }
338 }
339}
340
341// MARK: - Ref Picker Sheet
342
343private struct RefPickerSheet: View {
344 let viewModel: FileTreeViewModel
345 @Binding var isPresented: Bool
346
347 var body: some View {
348 NavigationStack {
349 List {
350 Section {
351 Button {
352 Task {
353 await viewModel.changeRevspec("HEAD")
354 isPresented = false
355 }
356 } label: {
357 refRow(
358 title: "HEAD",
359 systemImage: "arrow.triangle.branch",
360 color: .blue,
361 isSelected: viewModel.revspec == "HEAD"
362 )
363 }
364 .buttonStyle(.plain)
365 }
366
367 if !viewModel.branches.isEmpty {
368 Section("Branches") {
369 ForEach(viewModel.branches, id: \.name) { ref in
370 Button {
371 Task {
372 await viewModel.changeRevspec(ref.name)
373 isPresented = false
374 }
375 } label: {
376 refRow(
377 title: ref.name.replacingOccurrences(of: "refs/heads/", with: ""),
378 systemImage: "arrow.triangle.branch",
379 color: .blue,
380 isSelected: viewModel.revspec == ref.name
381 )
382 }
383 .buttonStyle(.plain)
384 }
385 }
386 }
387
388 if !viewModel.tags.isEmpty {
389 Section("Tags") {
390 ForEach(viewModel.tags, id: \.name) { ref in
391 Button {
392 Task {
393 await viewModel.changeRevspec(ref.name)
394 isPresented = false
395 }
396 } label: {
397 refRow(
398 title: ref.name.replacingOccurrences(of: "refs/tags/", with: ""),
399 systemImage: "tag",
400 color: .orange,
401 isSelected: viewModel.revspec == ref.name
402 )
403 }
404 .buttonStyle(.plain)
405 }
406 }
407 }
408 }
409 .listStyle(.insetGrouped)
410 .navigationTitle("Select Ref")
411 .navigationBarTitleDisplayMode(.inline)
412 .toolbar {
413 ToolbarItem(placement: .cancellationAction) {
414 Button("Cancel") {
415 isPresented = false
416 }
417 }
418 }
419 .overlay {
420 if viewModel.isLoadingRefs {
421 SRHTLoadingStateView(message: "Loading references…")
422 }
423 }
424 }
425 }
426
427 private func refRow(title: String, systemImage: String, color: Color, isSelected: Bool) -> some View {
428 HStack(spacing: 12) {
429 Image(systemName: systemImage)
430 .foregroundStyle(color)
431
432 Text(title)
433 .font(.body.monospaced())
434 .foregroundStyle(.primary)
435
436 Spacer()
437
438 if isSelected {
439 Image(systemName: "checkmark")
440 .font(.caption.weight(.semibold))
441 .foregroundStyle(.tint)
442 }
443 }
444 .contentShape(Rectangle())
445 }
446}