krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v2.4.0: 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}